Search code examples
pythondjangodjango-filter

Django-filter: ModelChoiceFilter with request.user based queryset


I have a Django class based ListView listing objects. These objects can be filtered based on locations. Now I want that the location ModelChoiceFilter only lists locations which are relevant to the current user. Relevant locations are the locations he owns. How can I change the queryset?

# models.py
from django.db import models
from django.conf import settings
from rules.contrib.models import RulesModel
from django.utils.translation import gettext_lazy as _


class Location(RulesModel):
    name = models.CharField(_("Name"), max_length=200)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Owner"),
        related_name="location_owner",
        on_delete=models.CASCADE,
        help_text=_("Owner can view, change or delete this location."),
    )

class Object(RulesModel):
    name = models.CharField(_("Name"), max_length=200)
    description = models.TextField(_("Description"), blank=True)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Owner"),
        related_name="location_owner",
        on_delete=models.CASCADE,
        help_text=_("Owner can view, change or delete this location."),
    )
    location = models.ForeignKey(
        Location,
        verbose_name=_("Location"),
        related_name="object_location",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )

This is my current filters.py file which shows all the locations to the user.

# filters.py
from .models import Object
import django_filters

class ObjectFilter(django_filters.FilterSet):
    class Meta:
        model = Object
        fields = ["location", ]

This is the view which by default shows objects the user owns. It's possible to filter further by location. But the location drop-down shows too many entries.

# views.py
from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Object
from .filters import ObjectFilter

class ObjectListView(LoginRequiredMixin, ListView):
    model = Object
    paginate_by = 10

    def get_queryset(self):
        queryset = Object.objects.filter(owner=self.request.user)
        filterset = ObjectFilter(self.request.GET, queryset=queryset)
        return filterset.qs

    def get_context_data(self, **kwargs):
        context = super(ObjectListView, self).get_context_data(**kwargs)
        filterset = ObjectFilter(self.request.GET, queryset=self.queryset)
        context["filter"] = filterset
        return context

My last attempt

I've tried to tweak the filters.py with adding a ModelChoiceFilter, but it ends up with an AttributeError: 'NoneType' object has no attribute 'request'.

# filters.py
from .models import Object
import django_filters

def get_location_queryset(self):
    queryset = Location.objects.filter(location__owner=self.request.user)
    return queryset

class ObjectFilter(django_filters.FilterSet):
    location = django_filters.filters.ModelChoiceFilter(queryset=get_location_queryset)

    class Meta:
        model = Object
        fields = ["location", ]

Solution

  • I believe a few different issues are at play here. First, as per the django-filter docs, when a callable is passed to ModelChoiceFilter, it will be invoked with Filterset.request as its only argument. So your filters.py would need to be rewritten like so:

    # filters.py
    from .models import Object
    import django_filters
    
    def get_location_queryset(request): # updated from `self` to `request`
        queryset = Location.objects.filter(location__owner=request.user)
        return queryset
    
    class ObjectFilter(django_filters.FilterSet):
        location = django_filters.filters.ModelChoiceFilter(queryset=get_location_queryset)
    
        class Meta:
            model = Object
            fields = ["location", ]
    

    This is half of the puzzle. I believe the other issue is in your view. django-filter has view classes that handle passing requests to filtersets, but this does not happen automatically using Django's generic ListView. Try updating your view code to something like this:

    # views.py
    from django_filters.views import FilterView
    
    class ObjectListView(LoginRequiredMixin, FilterView): # FilterView instead of ListView
        model = Object
        filterset_class = ObjectFilter
    

    This should take care of passing the request for you.

    Also note that, as per the django-filter docs linked above, your queryset should handle the case when request is None. I've personally never seen that happen in my projects, but just FYI.

    As an alternative, if you don't want to use FilterView, I believe the code in your example is almost there:

    # views.py alternative
    class ObjectListView(LoginRequiredMixin, ListView):
        model = Object
        paginate_by = 10
    
        def get_queryset(self):
            filterset = ObjectFilter(self.request)
            return filterset.qs
    

    I think this would also work with the filters.py I specified above.