I have been trying to get a ModelMultipleChoiceFilter to work for hours and have read both the DRF and Django Filters documentation.
I want to be able to filter a set of Websites based on the tags that have been assigned to them via a ManyToManyField. For example I want to be able to get a list of websites that have been tagged "Cooking" or "Beekeeping".
Here is the relevant snippet of my current models.py:
class SiteTag(models.Model):
"""Site Categories"""
name = models.CharField(max_length=63)
def __str__(self):
return self.name
class Website(models.Model):
"""A website"""
domain = models.CharField(max_length=255, unique=True)
description = models.CharField(max_length=2047)
rating = models.IntegerField(default=1, choices=RATING_CHOICES)
tags = models.ManyToManyField(SiteTag)
added = models.DateTimeField(default=timezone.now())
updated = models.DateTimeField(default=timezone.now())
def __str__(self):
return self.domain
And my current views.py snippet:
class WebsiteFilter(filters.FilterSet):
# With a simple CharFilter I can chain together a list of tags using &tag=foo&tag=bar - but only returns site for bar (sites for both foo and bar exist).
tag = django_filters.CharFilter(name='tags__name')
# THE PROBLEM:
tags = django_filters.ModelMultipleChoiceFilter(name='name', queryset=SiteTag.objects.all(), lookup_type="eq")
rating_min = django_filters.NumberFilter(name="rating", lookup_type="gte")
rating_max = django_filters.NumberFilter(name="rating", lookup_type="lte")
class Meta:
model = Website
fields = ('id', 'domain', 'rating', 'rating_min', 'rating_max', 'tag', 'tags')
class WebsiteViewSet(viewsets.ModelViewSet):
"""API endpoint for sites"""
queryset = Website.objects.all()
serializer_class = WebsiteSerializer
filter_class = WebsiteFilter
filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter,)
search_fields = ('domain',)
ordering_fields = ('id', 'domain', 'rating',)
I have just been testing with the querystring [/path/to/sites]?tags=News
and I am 100% sure that the appropriate records exist as they work (as described) with a ?tag
(missing the s
) query.
An example of the other things I have tried is something like:
tags = django_filters.ModelMultipleChoiceFilter(name='tags__name', queryset=Website.objects.all(), lookup_type="in")
How can I return any Website that has a SiteTag that satisfies name == A OR name == B OR name == C
?
MultipleChoiceFilter. The same as ChoiceFilter except the user can select multiple choices and the filter will form the OR of these choices by default to match items. The filter will form the AND of the selected choices when the conjoined=True argument is passed to this class.
Django Field Lookups Managers and QuerySet objects comes with a feature called lookups. A lookup is composed of a model field followed by two underscores ( __ ) which is then followed by lookup name.
I stumbled across this question while trying to solve a nearly identical problem to yourself, and while I could have just written a custom filter, your question got me intrigued and I had to dig deeper!
It turns out that a ModelMultipleChoiceFilter
only makes one change over a normal Filter
, as seen in the django_filters
source code below:
class ModelChoiceFilter(Filter):
field_class = forms.ModelChoiceField
class ModelMultipleChoiceFilter(MultipleChoiceFilter):
field_class = forms.ModelMultipleChoiceField
That is, it changes the field_class
to a ModelMultipleChoiceField
from Django's built in forms.
Taking a look at the source code for ModelMultipleChoiceField
, one of the required arguments to __init__()
is queryset
, so you were on the right track there.
The other piece of the puzzle comes from the ModelMultipleChoiceField.clean()
method, with a line: key = self.to_field_name or 'pk'
. What this means is that by default it will take whatever value you pass to it (eg.,"cooking"
) and try to look up Tag.objects.filter(pk="cooking")
, when obviously we want it to look at the name, and as we can see in that line, what field it compares to is controlled by self.to_field_name
.
Luckily, django_filters
's Filter.field()
method includes the following when instantiating the actual field.
self._field = self.field_class(required=self.required,
label=self.label, widget=self.widget, **self.extra)
Of particular note is the **self.extra
, which comes from Filter.__init__()
: self.extra = kwargs
, so all we need to do is pass an extra to_field_name
kwarg to the ModelMultipleChoiceFilter
and it will be handed through to the underlying ModelMultipleChoiceField
.
So (skip here for the actual solution!), the actual code you want is
tags = django_filters.ModelMultipleChoiceFilter(
name='sitetags__name',
to_field_name='name',
lookup_type='in',
queryset=SiteTag.objects.all()
)
So you were really close with the code you posted above! I don't know if this solution will be relevant to you anymore, but hopefully it might help someone else in the future!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With