djangodjango-querysetdjango-filterdjango-filters

How to filter by range OR "null" value? (I.e. combine NumberFilter range and BooleanFilter for null=True IntegerField)


I have a Item model with a numeric number field. This number field defaults to null.

# models.py
class Item(models.Model):
  number = models.IntegerField(default=None, blank=True, null=True)

I want to set-up filters that can return a queryset of Items where number is in range - which is straightforward enough:

# filters.py
class ItemFilter(django_filters.FilterSet):
  min_num = django_filters.NumberFilter(method="min_num_filter")
  max_num = django_filters.NumberFilter(method="max_num_filter")

  class Meta:
    model = Item
    fields = ("min_num", "max_num", "incl_null")

  def min_num_filter(self, queryset, name, value):
    return queryset.filter(number__gte=value)

  def max_num_filter(self, queryset, name, value):
    return queryset.filter(number__lte=value)

But what if I want to have an additional Boolean filter that can include Items that has null for number along with whatever Items matches the min_num and max_num range?

So for example, a URL query in the form of ?min_num=1&max_num=10&incl_null=True should return all Items where number is between 1 and 10 OR number is equal to None.

The following code does not work:

class ItemFilter(django_filters.FilterSet):
  ...
  incl_null = django_filters.BooleanFilter(method="incl_null_filter")

  class Meta:
    model = Item
    fields = ("min_num", "max_num", "incl_null")

  // doesn't work
  class incl_null_filter(self, queryset, name, value):
    if value is True:
      return queryset | Item.objects.filter(number=None)
    if value is False:
      return queryset

Edit: I've tried the methods in the "Filtering by empty values" documentation but I think that's for null values exclusively - where I'm looking for a range match OR a null value.


Solution

  • Well, the only solution I can think of is to pass the min range, max range, and is_null boolean into a single char field then convert it into the 3 individual filters for actioning.

    So the query URL will look like ?master_num=1-10-1 for range 1 - 10 incl. None and ?master_num=1-10-0 for range 1 - 10 excl. None.

    class ItemFilter(django_filters.FilterSet):
      master_num = django_filters.CharFilter(method="master_num_filter")
    
      class Meta:
        model = Item
        fields = ("master_num")
    
      def master_num_filter(self, queryset, name, value):
        # array = [min, max, 1 or 0 for True and False]
        array = value.split("-")
        min = Q(year_published__gte=int(array[0]))
        max = Q(year_published__lte=int(array[1]))
        if array[2] == "1":
            incl_null = Q(year_published=None)
            return queryset.filter((min & max) | incl_null)
        else:
            return queryset.filter(min & max)
    

    Would like to know if there's a better way to do this.