Grouped CheckboxSelectMultiple in Django template
Asked Answered
S

3

13

How can I group checkboxes produced by CheckboxSelectMultiple by a related model?

This is best demonstrated by example.

models.py:

class FeatureCategory(models.Model):
    name = models.CharField(max_length=30)

class Feature(models.Model):
    name = models.CharField(max_length=30)
    category = models.ForeignKey(FeatureCategory)

class Widget(models.Model):
    name = models.CharField(max_length=30)
    features = models.ManyToManyField(Feature, blank=True)

forms.py:

class WidgetForm(forms.ModelForm):
    features = forms.ModelMultipleChoiceField(
        queryset=Feature.objects.all(),
        widget=forms.CheckboxSelectMultiple,
        required=False
    )
    class Meta:
        model = Widget

views.py:

def edit_widget(request):
    form = WidgetForm()
    return render(request, 'template.html', {'form': form})

template.html:

{{ form.as_p }}

The above produces the following output:

[] Widget 1
[] Widget 2
[] Widget 3
[] Widget 1
[] Widget 2

What I would like is for the feature checkboxes to be grouped by feature category (based on the ForeignKey):

Category 1:
  [] Widget 1
  [] Widget 2
  [] Widget 3

Category 2:
  [] Widget 1
  [] Widget 2

How can I achieve this? I have tried using the {% regroup %} template tag to no avail.

Any advice much appreciated.

Thanks.

Savil answered 16/12, 2012 at 1:15 Comment(0)
D
19

You have to write the custom CheckboxSelectMultiple widget. Using the snippet I have tried make the CheckboxSelectMultiple field iterable by adding the category_name as an attribute in field attrs. So that I can use regroup tag in template later on.

The below code is modified from snippet according to your need, obviously this code can be made more cleaner and more generic, but at this moment its not generic.

forms.py

from django import forms
from django.forms import Widget
from django.forms.widgets import SubWidget
from django.forms.util import flatatt
from django.utils.html import conditional_escape
from django.utils.encoding import StrAndUnicode, force_unicode
from django.utils.safestring import mark_safe

from itertools import chain
import ast

from mysite.models import Widget as wid # your model name is conflicted with django.forms.Widget
from mysite.models import Feature

class CheckboxInput(SubWidget):
    """
    An object used by CheckboxRenderer that represents a single
    <input type='checkbox'>.
    """
    def __init__(self, name, value, attrs, choice, index):
        self.name, self.value = name, value
        self.attrs = attrs
        self.choice_value = force_unicode(choice[1])
        self.choice_label = force_unicode(choice[2])

        self.attrs.update({'cat_name': choice[0]})

        self.index = index

    def __unicode__(self):
        return self.render()

    def render(self, name=None, value=None, attrs=None, choices=()):
        name = name or self.name
        value = value or self.value
        attrs = attrs or self.attrs

        if 'id' in self.attrs:
            label_for = ' for="%s_%s"' % (self.attrs['id'], self.index)
        else:
            label_for = ''
        choice_label = conditional_escape(force_unicode(self.choice_label))
        return mark_safe(u'<label%s>%s %s</label>' % (label_for, self.tag(), choice_label))

    def is_checked(self):
        return self.choice_value in self.value

    def tag(self):
        if 'id' in self.attrs:
            self.attrs['id'] = '%s_%s' % (self.attrs['id'], self.index)
        final_attrs = dict(self.attrs, type='checkbox', name=self.name, value=self.choice_value)
        if self.is_checked():
            final_attrs['checked'] = 'checked'
        return mark_safe(u'<input%s />' % flatatt(final_attrs))

class CheckboxRenderer(StrAndUnicode):
    def __init__(self, name, value, attrs, choices):
        self.name, self.value, self.attrs = name, value, attrs
        self.choices = choices

    def __iter__(self):
        for i, choice in enumerate(self.choices):
            yield CheckboxInput(self.name, self.value, self.attrs.copy(), choice, i)

    def __getitem__(self, idx):
        choice = self.choices[idx] # Let the IndexError propogate
        return CheckboxInput(self.name, self.value, self.attrs.copy(), choice, idx)

    def __unicode__(self):
        return self.render()

    def render(self):
        """Outputs a <ul> for this set of checkbox fields."""
        return mark_safe(u'<ul>\n%s\n</ul>' % u'\n'.join([u'<li>%s</li>'
                % force_unicode(w) for w in self]))

class CheckboxSelectMultipleIter(forms.CheckboxSelectMultiple):
    """
    Checkbox multi select field that enables iteration of each checkbox
    Similar to django.forms.widgets.RadioSelect
    """
    renderer = CheckboxRenderer

    def __init__(self, *args, **kwargs):
        # Override the default renderer if we were passed one.
        renderer = kwargs.pop('renderer', None)
        if renderer:
            self.renderer = renderer
        super(CheckboxSelectMultipleIter, self).__init__(*args, **kwargs)

    def subwidgets(self, name, value, attrs=None, choices=()):
        for widget in self.get_renderer(name, value, attrs, choices):
            yield widget

    def get_renderer(self, name, value, attrs=None, choices=()):
        """Returns an instance of the renderer."""

        choices_ = [ast.literal_eval(i[1]).iteritems() for i in self.choices]
        choices_ = [(a[1], b[1], c[1]) for a, b, c in choices_]

        if value is None: value = ''
        str_values = set([force_unicode(v) for v in value]) # Normalize to string.
        if attrs is None:
            attrs = {}
        if 'id' not in attrs:
            attrs['id'] = name
        final_attrs = self.build_attrs(attrs)
        choices = list(chain(choices_, choices))
        return self.renderer(name, str_values, final_attrs, choices)

    def render(self, name, value, attrs=None, choices=()):
        return self.get_renderer(name, value, attrs, choices).render()

    def id_for_label(self, id_):
        if id_:
            id_ += '_0'
        return id_

class WidgetForm(forms.ModelForm):
    features = forms.ModelMultipleChoiceField(
        queryset=Feature.objects.all().values('id', 'name', 'category__name'),
        widget=CheckboxSelectMultipleIter,
        required=False
    )
    class Meta:
        model = wid

Then in template:

{% for field in form %}
{% if field.name == 'features' %} 
    {% regroup field by attrs.cat_name as list %}

    <ul>
    {% for el in list %}
        <li>{{el.grouper}}
        <ul>
            {% for e in el.list %}
                {{e}} <br />
            {% endfor %}
        </ul>
        </li>
    {% endfor %}
    </ul>
{% else %}
    {{field.label}}: {{field}}
{% endif %}

{% endfor %}

Results: I added countries name in category table, and cities name in features table so in template I was able to regroup the cities (features) according to country (category)

enter image description here

Did answered 18/12, 2012 at 13:32 Comment(4)
I was hoping that there would be a built-in way, but this does exactly what I was looking for. Thank you.Savil
Should this code still work? I just attempted to use it and got lost in error messages related to ast.Thermotensile
@Thermotensile If you can give more details about the issues you are having maybe I would be able to help then.Did
this answer may be seen for a minimalistic, up-to-date example of overriding CheckboxSelectMultiple: #39257550Lording
E
1

I have recently been looking for this too, working with Django 4.2. Here is what I came up with, I hope it helps someone.

First, a generic widget. Note that there is some styling done here, with classes like form-check being added to the rendered output. Mine comes from Bootstrap, but you can of course define the css the way you want.

## widgets.py

from django.forms import CheckboxInput
from django.forms.widgets import CheckboxSelectMultiple
from django.utils.encoding import force_str
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe

class CheckboxSelectMultipleByCategory(CheckboxSelectMultiple):

    """
    Displays a ModelMultipleChoiceField, organizing the choices by categories

    child_model contains the choices
    category_model contains the categories

    assumption : child_model has a category_id field,
    ForeignKey to category_model
    """

    def __init__(self, child_model, category_model):
        super().__init__()

        self.category_model = category_model
        self.child_model = child_model

    def render(self, name, value, renderer, attrs=None):
        if value is None:
            value = []
        has_id = attrs and "id" in attrs
        final_attrs = self.build_attrs(attrs)
        output = ["<div>"]
        # Normalize to strings

        str_values = set([force_str(v) for v in value])
        supercategories = self.category_model.objects.all()

        for supercategory in supercategories:
            output.append(
                '<span class="form-category">%s</span>' % (str(supercategory))
            )
            output.append('<div class="form-check">')

            options = self.child_model.objects.filter(category_id=supercategory)

            for option in options:
                option_value = force_str(option.pk)
                option_label = str(option)

                if has_id:
                    final_attrs = dict(
                        final_attrs, id="%s_%s" % (attrs["id"], option_value)
                    )
                    final_attrs["class"] = "form-check-input"
                    label_for = ' for="%s"' % final_attrs["id"]
                else:
                    label_for = ""
                cb = CheckboxInput(
                    final_attrs, check_test=lambda value: value in str_values
                )

                rendered_cb = cb.render(name, option_value)
                option_label = conditional_escape(force_str(option_label))
                output.append(
                    '<div>%s<label class="form-check-label" %s>%s</label></div>'
                    % (rendered_cb, label_for, option_label)
                )
            output.append("</div>")
        output.append("</div>")
        return mark_safe("\n".join(output))

Then, the models. In the above widget, we call str(supercategory) and str(option), so make sure to define __str__ for your models, to have the desired human-readable output :

## models.py

class Category(models.Model) :
    id = models.AutoField(primary_key=True)
    category_name = models.CharField()
    
    def __str__(self):
        return self.category_name

class Categorized(models.Model) :
    id = models.AutoField(primary_key=True)
    name = models.CharField()
    category = models.ForeignKey("Category")

    def __str__(self):
        return self.name

In any form, you can now specify that you want to use this widget. Instantiate it with the models you want to use, like this :

## forms.py

from django import forms
from .models import Category, Categorized
from .widgets import CheckboxSelectMultipleByCategory

class MyForm(forms.ModelForm) :

    ## ...
    categories = forms.ModelMultipleChoiceField(
        queryset=Categorized.objects.all(),
        widget = CheckboxSelectMultipleByCategory(
            Categorized, Category
        ),
        required=False
    )
    ## ...

Finally, there isn't much left to do in your template :

...
    {{ form.categories }}
...
Eward answered 1/9, 2023 at 9:21 Comment(0)
D
0

Here's a solution for current versions of Django (~2.1).

## forms.py

from itertools import groupby
from django import forms
from django.forms.models import ModelChoiceIterator, ModelMultipleChoiceField

from .models import Feature, Widget


class GroupedModelMultipleChoiceField(ModelMultipleChoiceField):

    def __init__(self, group_by_field, group_label=None, *args, **kwargs):
        """
        ``group_by_field`` is the name of a field on the model
        ``group_label`` is a function to return a label for each choice group

        """
        super(GroupedModelMultipleChoiceField, self).__init__(*args, **kwargs)
        self.group_by_field = group_by_field
        if group_label is None:
            self.group_label = lambda group: group
        else:
            self.group_label = group_label

    def _get_choices(self):
        if hasattr(self, '_choices'):
            return self._choices
        return GroupedModelChoiceIterator(self)
    choices = property(_get_choices, ModelMultipleChoiceField._set_choices)


class GroupedModelChoiceIterator(ModelChoiceIterator):

    def __iter__(self):
        """Now yields grouped choices."""            
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        for group, choices in groupby(
                self.queryset.all(),
                lambda row: getattr(row, self.field.group_by_field)):
            if group is None:
                for ch in choices:
                    yield self.choice(ch)
            else:
                yield (
                    self.field.group_label(group),
                    [self.choice(ch) for ch in choices])


class WidgetForm(forms.ModelForm):
    class Meta:
        model = Widget
        fields = ['features',]

    def __init__(self, *args, **kwargs):
        super(WidgetForm, self).__init__(*args, **kwargs)
        self.fields['features'] = GroupedModelMultipleChoiceField(
            group_by_field='category',
            queryset=Feature.objects.all(),
            widget=forms.CheckboxSelectMultiple(),
            required=False)

Then you can use {{ form.as_p }} in the template for properly grouped choices.

If you would like to use the regroup template tag and iterate over the choices, you will also need to reference the following custom widget:

class GroupedCheckboxSelectMultiple(forms.CheckboxSelectMultiple):

    def optgroups(self, name, value, attrs=None):
        """
        The group name is passed as an argument to the ``create_option`` method (below).

        """
        groups = []
        has_selected = False

        for index, (option_value, option_label) in enumerate(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 = (
                    str(subvalue) in value and
                    (not has_selected or self.allow_multiple_selected)
                )
                has_selected |= selected
                subgroup.append(self.create_option(
                    name, subvalue, sublabel, selected, index,
                    subindex=subindex, attrs=attrs, group=group_name,
                ))
                if subindex is not None:
                    subindex += 1
        return groups

    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None, group=None):
        """
        Added a ``group`` argument which is included in the returned dictionary.

        """
        index = str(index) if subindex is None else "%s_%s" % (index, subindex)
        if attrs is None:
            attrs = {}
        option_attrs = self.build_attrs(self.attrs, attrs) if self.option_inherits_attrs else {}
        if selected:
            option_attrs.update(self.checked_attribute)
        if 'id' in option_attrs:
            option_attrs['id'] = self.id_for_label(option_attrs['id'], index)
        return {
            'name': name,
            'value': value,
            'label': label,
            'selected': selected,
            'index': index,
            'attrs': option_attrs,
            'type': self.input_type,
            'template_name': self.option_template_name,
            'wrap_label': True,
            'group': group,
        }


class WidgetForm(forms.ModelForm):
    class Meta:
        model = Widget
        fields = ['features',]

    def __init__(self, *args, **kwargs):
        super(WidgetForm, self).__init__(*args, **kwargs)
        self.fields['features'] = GroupedModelMultipleChoiceField(
            group_by_field='category',
            queryset=Feature.objects.all(),
            widget=GroupedCheckboxSelectMultiple(),
            required=False)

Then the following should work in your template:

{% regroup form.features by data.group as feature_list %}
{% for group in feature_list %}
<h6>{{ group.grouper|default:"Other Features" }}</h6>
<ul>
  {% for choice in group.list %}
  <li>{{ choice }}</li>
  {% endfor %}
</ul>
</div>
{% endfor %}

Credit to the following page for part of the solution:

https://mounirmesselmeni.github.io/2013/11/25/django-grouped-select-field-for-modelchoicefield-or-modelmultiplechoicefield/

Determinant answered 15/10, 2018 at 11:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.