How to add optgroups to a django ModelMultipleChoiceField?

Question:

I have a form with a ModelMultipleChoiceField to list of categories.
I would like to group categories using the Category.group field.

I thought that by changing the field.choices in the init function it will make the trick

class CategoriesField(forms.ModelMultipleChoiceField):
    def __init__(self, queryset, **kwargs):
        super(forms.ModelMultipleChoiceField, self).__init__(queryset, **kwargs)
        self.queryset = queryset.select_related()
        self.to_field_name=None

        group = None
        list = []
        self.choices = []

        for category in queryset:
            if not group:
                group = category.group

            if group != category.group:
                self.choices.append((group.title, list))
                group = category.group
                list = [(category.id, category.name)]
            else:
                list.append((category.id, category.name))
        try:
            self.choices.append((group.title, list))
        except:
            pass

But the ModelChoiceIterator still erase the self.choices info that are set in the __init__ function.

How can I do that the right way ?

Asked By: Natim

||

Answers:

Actually it is working like I just explain but dont forget that part :

class ProfilForm(ModelForm):
    categories  = CategoriesField(queryset=Category.objects.all().order_by('group'), label=_(u'Catégories'))
Answered By: Natim

I found this question/answer helpful but changed the code a lot. The problem with the above code is that it only generates the list once and then it’s cached (the queryset is only used once). My code is for an “Article” objects that’s ordered by “profile” (aka author), but anyone should be able to modify it for their use. It uses a new queryset each time so it’s updated without a restart (unless you pass cache_choices=True to ArticleMultipleChoiceField and then it’s cached).

class ArticleChoiceIterator(forms.models.ModelChoiceIterator):
    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        if self.field.cache_choices:
            if self.field.choice_cache is None:
                last_profile = None
                self.field.choice_cache = []
                for article in self.queryset.all():
                    if last_profile != article.profile:
                        last_profile = article.profile
                        article_list = []
                        self.field.choice_cache.append((article.profile.name, article_list))
                    article_list.append(self.choice(article))
            for choice in self.field.choice_cache:
                yield choice
        else:
            last_profile = None
            article_choices = []
            for article in self.queryset.all():
                if last_profile != article.profile:
                    if article_choices:
                        yield (getattr(last_profile, 'name', ''), article_choices)
                    last_profile = article.profile
                    article_choices = []
                article_choices.append(self.choice(article))
            if article_choices:
                yield (getattr(last_profile, 'name', ''), article_choices)


class ArticleMultipleChoiceField(forms.ModelMultipleChoiceField):
    # make sure queryset is ordered by profile first!
    def __init__(self, queryset, **kwargs):
        super(ArticleMultipleChoiceField, self).__init__(queryset, **kwargs)
        self.queryset = queryset.select_related('profile')
        self._choices = ArticleChoiceIterator(self)


class PackageForm(forms.ModelForm):
    articles = ArticleMultipleChoiceField(
        queryset=Article.objects.order_by('profile__name', 'title')
    )
Answered By: Tim Tisdall

Yor code worked for me, thank you! It is also possible to use with models.ManyToManyField which may be interesting for others, too:

from django.db import models

class CustomManyToManyField(models.ManyToManyField):
    def formfield(self, *args, **kwargs):
        kwargs["form_class"] = CategoriesField
        return super().formfield(**kwargs)

I had the same caching error, and also fixed it by putting the whole choice update in an iterator:

class CustomModelChoiceIterator(forms.models.ModelChoiceIterator):
    def __iter__(self):
        group = ""
        subgroup = []
        for category in self.queryset:
            if not group:
                group = category.group

            if group != category.group:
                yield (group.title, subgroup)
                group = category.group
                subgroup = [(category.id, category.name)]
            else:
                subgroup.append((category.id, category.name))
        yield (group.title, subgroup)


class CategoriesField(forms.ModelMultipleChoiceField):
    iterator = CustomModelChoiceIterator
Answered By: ChrisRob

In 2022 try:

class GroupedModelChoiceIterator(ModelChoiceIterator):
    def __init__(self, field, groupby):
        self.groupby = groupby
        super().__init__(field)

    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        queryset = self.queryset

        # Can't use iterator() when queryset uses prefetch_related()
        if not queryset._prefetch_related_lookups:
            queryset = queryset.iterator()
        for group, objs in groupby(queryset, self.groupby):
            yield (group, [self.choice(obj) for obj in objs])


class GroupedModelMultipleChoiceField(ModelMultipleChoiceField):
    def __init__(self, *args, choices_groupby, **kwargs):
        if isinstance(choices_groupby, str):
            choices_groupby = attrgetter(choices_groupby)
        elif not callable(choices_groupby):
            raise TypeError('choices_groupby must either be a str or a callable accepting a single argument')
        self.iterator = partial(GroupedModelChoiceIterator, groupby=choices_groupby)
        super().__init__(*args, **kwargs)
Answered By: alias51