[Django]-Django-filter: filtering by model property

3đź‘Ť

Have you got difficult property or not?
If not you can rewrite it to queryset like this:

from django.db import models

class UserQueryset(models.Manager):

    def get_queryset(self):

        return super().get_queryset().annotate(
            has_profile=models.Exists(Profile.objects.filter(user_id=models.OuterRef('id')))
        )

class User(models.Model):
    objects = UserQueryset


class Profile(models.Model):
    user = models.OneToOneField(User, related_name='profile')


# When you want to filter by has profile just use it like has field has profile

user_with_profiles = User.objects.filter(has_profile=True)

Maybe it is not what you want, but it can help you in some cases

1đź‘Ť

django-filter wants and assumes that you are using querysets. Once you take a queryset and change it into a list, then whatever is downstream needs to be able to handle just a list or just iterate through the list, which is no longer a queryset.

If you have a django_filters.FilterSet like:

class FooFilterset(django_filters.FilterSet):
    bar = django_filters.Filter('updated', lookup_expr='exact')
    my_property_filter = MyPropertyFilter('property')
    class Meta:
        model = Foo
        fields = ('bar',  'my_property_filter')

then you can write MyPropertyFilter like:

class MyPropertyFilter(django_filters.Filter):
    def filter(self, qs, value):
        return [row for row in qs if row.baz == value]

At this point, anything downstream of MyProperteyFilter will have a list.

Note: I believe the order of fields should have your custom filter, MyPropertyFilter last, because then it will always be processed after the normal queryset filters.


So, you have just broken the “queryset” API, for certain values of broken. At this point, you’ll have to work through the errors of whatever is downstream. If whatever is after the FilterSet requires a .count member, you can change MyPropertyFilter like:

class MyPropertyFilter(django_filters.Filter):
    def filter(self, qs, value):
        result = [row for row in qs if row.baz == value]
        result.count = len(result)
        return result

You’re in uncharted territory, and you’ll have to hack your way through.

Anyways, I’ve done this before and it isn’t horrible. Just take the errors as they come.

👤Ross Rogers

1đź‘Ť

Since filtering by non-field attributes such as property inevitably converts the QuerySet to list (or similar), I like to postpone it and do the filtering on object_list in get_context_data method. To keep the filtering logic inside the filterset class, I use a simple trick. I’ve defined a decorator

def attr_filter(func):

    def wrapper(self, queryset, name, value, force=False, *args, **kwargs):
        if force:
            return func(self, queryset, name, value, *args, **kwargs)
        else:
            return queryset
    return wrapper

which is used on django-filter non-field filtering methods. Thanks to this decorator, the filtering basically does nothing (or skips) the non-field filtering methods (because of force=False default value).

Next, I defined a Mixin to be used in the view class.

    class FilterByAttrsMixin:
    
        def get_context_data(self, **kwargs):
            context = super().get_context_data(**kwargs)
            filtered_list = self.filter_qs_by_attributes(self.object_list, self.filterset)
            context.update({
                'object_list': filtered_list,
            })
            return context
    
        def filter_qs_by_attributes(self, queryset, filterset_instance):
            if hasattr(filterset_instance.form, 'cleaned_data'):
                for field_name in filter_instance.filters:
                    method_name = f'attr_filter_{field_name}'
                    if hasattr(filterset_instance, method_name):
                        value = filterset_instance.form.cleaned_data[field_name]
                        if value:
                            queryset = getattr(filterset_instance, filter_method_name)(queryset, field_name, value, force=True)
            return queryset

It basically just returns to your filterset and runs all methods called attr_filter_<field_name>, this time with force=True.

In summary, you need to:

  • Inherit the FilterByAttrsMixin in your view class
  • call your filtering method attr_filter_<field_name>
  • use attr_filter decorator on the filtering method

Simple example (given that I have model called MyModel with property called is_static that I want to filter by:

model:

class MyModel(models.Model):
    ...

@property
def is_static(self):
    ...

view:

class MyFilterView(FilterByAttrsMixin, django_filters.views.FilterView):
    ...
    filterset_class = MyFiltersetClass
    ...

filter:

class MyFiltersetClass(django_filters.FilterSet):
    is_static = django_filters.BooleanFilter(
        method='attr_filter_is_static',
    )

    class Meta:
        model = MyModel
        fields = [...]

    @attr_filter
    def attr_filter_is_static(self, queryset, name, value):
        return [instance for instance in queryset if instance.is_static]
👤barneytabor

1đź‘Ť

Take a look at django-property-filter package. This is an extension to django-filter and provides functionality to filter querysets by class properties.

Short example from the documentation:

from django_property_filter import PropertyNumberFilter, PropertyFilterSet

class BookFilterSet(PropertyFilterSet):
    prop_number = PropertyNumberFilter(field_name='discounted_price', lookup_expr='gte')

    class Meta:
        model = NumberClass
        fields = ['prop_number']

Leave a comment