How to use ModelMultipleChoiceFilter?
Asked Answered
G

2

21

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?

Gearldinegearshift answered 6/10, 2014 at 4:56 Comment(1)
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? 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 :)Gearldinegearshift
A
31

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!

Azoth answered 3/9, 2015 at 7:55 Comment(8)
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.Gearldinegearshift
I used Django Filters for my current project and ran into this issue again. Thanks for the answer!Gearldinegearshift
This seems to help, but when multiple values are provided via two or more GET params, it doesn't work unfortunately.Gaudery
Great answer. I think there might be a mistake. Instead of: name='sitetags__name' should be: name='sitetags'.Zagreus
@Zagreus 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 complexAzoth
this solution need to be marked as correct answer. @ProfSmiles many thx to you!Origin
@ProfSmiles, Do you think you can update the answer to be relevant to the current versions? ModelMultipleChoiceFilter doesn't support name and lookup_typeDioptase
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') 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.Befuddle
G
1

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!

Gaudery answered 19/12, 2015 at 5:14 Comment(2)
Won't this get the races that are both black AND white? Black OR white should look like ?race=w,b no?Quintanilla
I'm not sure anymore, but it's been live for years now.Gaudery

© 2022 - 2024 — McMap. All rights reserved.