Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use ModelMultipleChoiceFilter?

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?

like image 907
Daniel Devine Avatar asked Oct 06 '14 04:10

Daniel Devine


People also ask

How do you use MultipleChoiceFilter?

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.

What is __ in Django ORM?

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.


1 Answers

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!

like image 103
ProfSmiles Avatar answered Sep 22 '22 07:09

ProfSmiles