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 ?
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'))
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')
)
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
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)
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 ?
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'))
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')
)
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
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)