19

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?

Daniel Devine
  • 283
  • 1
  • 2
  • 13
  • I have resolved my issue for now by following the lead of [Possible to do an `in` `lookup_type` through the django-filter URL parser?](https://stackoverflow.com/questions/24041639/possible-to-do-an-in-lookup-type-through-the-django-filter-url-parser?rq=1) and making a custom Filter. I'm still interested in seeing the solution to my problem as I am sure it will help somebody else - and code that I don't use won't have bugs :) – Daniel Devine Oct 06 '14 at 11:33

2 Answers2

29

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!

Grace B
  • 1,386
  • 11
  • 30
  • 1
    The project ended up being shelved because it was too contentious - but it's the second time I have written that sort of functionality. I'm likely to use Django Filters again so I'll be pretty happy when the third time rocks up! I think it would be worth working your solution into the official documentation. Let me know if you can't be bothered doing it (and getting the street cred yourself) - I'll try to find time. – Daniel Devine Sep 03 '15 at 11:58
  • 1
    I used Django Filters for my current project and ran into this issue again. Thanks for the answer! – Daniel Devine Oct 26 '15 at 17:01
  • This seems to help, but when multiple values are provided via two or more GET params, it doesn't work unfortunately. – mlissner Dec 19 '15 at 04:54
  • Great answer. I think there might be a mistake. Instead of: name='sitetags__name' should be: name='sitetags'. – RKI Feb 09 '16 at 12:26
  • 2
    @RKI In OP's case, I think you're right that just `name='sitetags'` would work, but I think only because `SiteTag`'s `__str__` method is `return self.name`. The extra `__name` just allows you to specify what field exactly you want it to match to, and allows it to still work when the `__str__` method returns something more complex – Grace B Feb 10 '16 at 04:39
  • this solution need to be marked as correct answer. @ProfSmiles many thx to you! – Anton Manevskiy Dec 27 '16 at 12:23
  • @ProfSmiles, Do you think you can update the answer to be relevant to the current versions? ModelMultipleChoiceFilter doesn't support name and lookup_type – Sai Chander Apr 29 '21 at 08:18
  • Is there a way to make it case-insensitive? in my example: `location__multiple = filters.ModelMultipleChoiceFilter(field_name='computed_current_location__location_to', to_field_name='location_to', queryset=WorkLocation.objects.all() lookup_expr='unaccent')` http://localhost:8000/api/v2/works/works/?location__multiple=cfa21 raises this exception: `"Select a valid choice. cfa21 is not one of the available choices."` The value in the db is CFA21. – StriveForBest Sep 13 '21 at 18:52
0

The solution that worked for me was to use a MultipleChoiceFilter. In my case, I have judges that have races, and I want my API to let people query for, say, either black or white judges.

The filter ends up being:

race = filters.MultipleChoiceFilter(
    choices=Race.RACES,
    action=lambda queryset, value:
        queryset.filter(race__race__in=value)
)

Race is a many to many field off of Judge:

class Race(models.Model):
    RACES = (
        ('w', 'White'),
        ('b', 'Black or African American'),
        ('i', 'American Indian or Alaska Native'),
        ('a', 'Asian'),
        ('p', 'Native Hawaiian or Other Pacific Islander'),
        ('h', 'Hispanic/Latino'),
    )
    race = models.CharField(
        choices=RACES,
        max_length=5,
    )

I'm not a huge fan of lambda functions usually, but it made sense here because it's such a small function. Basically, this sets up a MultipleChoiceFilter that passes the values from the GET parameters to the race field of the Race model. They're passed in as a list, so that's why the in parameter works.

So, my users can do:

/api/judges/?race=w&race=b

And they'll get back judges that have identified as either black or white.

PS: Yes, I recognize that this isn't the entire set of possible races. But it is what the U.S. census collects!

mlissner
  • 17,359
  • 18
  • 106
  • 169