Repository: openwisp/openwisp-users (language: python)

Issue:
[bug] Multi-tenant capabilities missing for `django-filters` 
I think the `openwisp-users` have [implemented multi-tenant capabilities for DRF views, serializers, and Django admin filters](https://github.com/openwisp/openwisp-users#django-rest-framework-mixins), but they do not have it for [django-filters](https://django-filter.readthedocs.io/en/stable/).

This bug is spotted here: https://github.com/openwisp/openwisp-controller/pull/748#issuecomment-1476062204


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index f7ae1a4a..cae383b7 100644
--- a/README.rst
+++ b/README.rst
@@ -977,6 +977,70 @@ which is inheriting any of the mixin classes.
 Usage example: `organization_field
 <https://github.com/openwisp/openwisp-users#organization_field>`_.
 
+Multi-tenant filters capabilities for the browsable web UI
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Integration of `Django filters <https://django-filter.readthedocs.io/en/stable/guide/rest_framework.html>`_
+with `Django REST Framework <https://www.django-rest-framework.org/>`_
+is provided through a DRF-specific ``FilterSet`` and a ``filter backend``.
+
+The relationship fields of ``django-filters`` show all the available results,
+without filtering by the organization the user has access to,
+which breaks multi-tenancy.
+
+The ``FilterDjangoByOrgMembership``, ``FilterDjangoByOrgManaged``
+and ``FilterDjangoByOrgOwned`` can be used to solve this issue.
+
+Usage example:
+
+.. code-block:: python
+
+   from django_filters import rest_framework as filters
+   from openwisp_users.api.mixins import FilterDjangoByOrgManaged
+   from ..models import FloorPlan
+
+
+   class FloorPlanOrganizationFilter(FilterDjangoByOrgManaged):
+       organization_slug = filters.CharFilter(field_name='organization__slug')
+
+       class Meta:
+           model = FloorPlan
+           fields = ['organization', 'organization_slug']
+
+
+   class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
+       serializer_class = FloorPlanSerializer
+       queryset = FloorPlan.objects.select_related().order_by('-created')
+       pagination_class = ListViewPagination
+       filter_backends = [filters.DjangoFilterBackend]
+       filterset_class = FloorPlanOrganizationFilter
+
+You can also use the organization filter classes
+such as ``OrganizationManagedFilter`` from ``openwisp_users.api.filters``
+which includes ``organization`` and ``organization_slug`` filter fields by default.
+
+Usage example:
+
+.. code-block:: python
+
+   from django_filters import rest_framework as filters
+   from openwisp_users.api.filters import OrganizationManagedFilter
+   from ..models import FloorPlan
+
+
+   class FloorPlanFilter(OrganizationManagedFilter):
+
+       class Meta(OrganizationManagedFilter.Meta):
+           model = FloorPlan
+
+
+   class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
+       serializer_class = FloorPlanSerializer
+       queryset = FloorPlan.objects.select_related().order_by('-created')
+       pagination_class = ListViewPagination
+       filter_backends = [filters.DjangoFilterBackend]
+       filterset_class = FloorPlanFilter
+
 Admin Multitenancy mixins
 -------------------------
 
diff --git a/openwisp_users/api/filters.py b/openwisp_users/api/filters.py
new file mode 100644
index 00000000..c31d525e
--- /dev/null
+++ b/openwisp_users/api/filters.py
@@ -0,0 +1,43 @@
+from django_filters import rest_framework as filters
+
+from .mixins import (
+    FilterDjangoByOrgManaged,
+    FilterDjangoByOrgMembership,
+    FilterDjangoByOrgOwned,
+)
+
+
+class BaseOrganizationFilter:
+    """
+    A class that provides organization and organization slug filter fields
+    """
+
+    class Meta:
+        fields = ['organization', 'organization_slug']
+
+
+class OrganizationMembershipFilter(BaseOrganizationFilter, FilterDjangoByOrgMembership):
+    """
+    A Django filter class that can be used in various OpenWISP API views
+    to filter relation fields based on the user organization membership
+    """
+
+    organization_slug = filters.CharFilter(field_name='organization__slug')
+
+
+class OrganizationManagedFilter(BaseOrganizationFilter, FilterDjangoByOrgManaged):
+    """
+    A Django Filter class that can be used in various OpenWISP API views
+    to filter relation fields based on the organization managed by the user
+    """
+
+    organization_slug = filters.CharFilter(field_name='organization__slug')
+
+
+class OrganizationOwnedFilter(BaseOrganizationFilter, FilterDjangoByOrgOwned):
+    """
+    A Django Filter class that can be used in various OpenWISP API views
+    to filter relation fields based on the organization owned by the user
+    """
+
+    organization_slug = filters.CharFilter(field_name='organization__slug')
diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py
index c3e8f1ae..0d1c74f6 100644
--- a/openwisp_users/api/mixins.py
+++ b/openwisp_users/api/mixins.py
@@ -1,6 +1,8 @@
 import swapper
 from django.core.exceptions import ValidationError
-from django.db.models import Q
+from django.db.models import ForeignKey, ManyToManyField, Q
+from django_filters import rest_framework as filters
+from django_filters.filters import QuerySetRequestMixin as BaseQuerySetRequestMixin
 from rest_framework.authentication import SessionAuthentication
 from rest_framework.exceptions import NotFound
 from rest_framework.permissions import IsAuthenticated
@@ -212,6 +214,86 @@ class FilterSerializerByOrgOwned(FilterSerializerByOrganization):
     _user_attr = 'organizations_owned'
 
 
+class QuerySetRequestMixin(BaseQuerySetRequestMixin):
+    def get_queryset(self, request):
+        user = request.user
+        queryset = super().get_queryset(request)
+        # superuser can see everything
+        if user.is_superuser or user.is_anonymous:
+            return queryset
+        # non superusers can see only items
+        # of organizations they're related to
+        organization_filter = getattr(user, self._user_attr)
+        # if field_name organization then just organization_filter
+        if self.field_name == 'organization':
+            return queryset.filter(pk__in=organization_filter)
+        # for field_name other than organization
+        conditions = Q(**{'organization__in': organization_filter})
+        return queryset.filter(conditions)
+
+    def __init__(self, *args, **kwargs):
+        self._user_attr = kwargs.pop('user_attr')
+        super().__init__(*args, **kwargs)
+
+
+class DjangoOrganizationFilter(filters.ModelChoiceFilter, QuerySetRequestMixin):
+    pass
+
+
+class DjangoOrganizationM2MFilter(
+    filters.ModelMultipleChoiceFilter, QuerySetRequestMixin
+):
+    pass
+
+
+class FilterDjangoOrganization(filters.FilterSet):
+    """
+    A custom filter set class that applies DjangoOrganizationFilter
+    to all ModelChoiceFilter & ModelMultipleChoiceFilterfilters.
+    """
+
+    @classmethod
+    def filter_for_field(cls, field, name, lookup_expr='exact'):
+        if isinstance(field, ForeignKey) or isinstance(field, ManyToManyField):
+            if field.name == 'user':
+                return super().filter_for_field(field, name, lookup_expr)
+            opts = dict(
+                queryset=field.remote_field.model.objects.all(),
+                label=field.verbose_name.capitalize(),
+                field_name=field.name,
+                user_attr=cls._user_attr,
+            )
+            if isinstance(field, ForeignKey):
+                return DjangoOrganizationFilter(**opts)
+            if isinstance(field, ManyToManyField):
+                return DjangoOrganizationM2MFilter(**opts)
+        return super().filter_for_field(field, name, lookup_expr)
+
+
+class FilterDjangoByOrgMembership(FilterDjangoOrganization):
+    """
+    Filter django-filters by organizations the user is member of
+    """
+
+    _user_attr = 'organizations_dict'
+
+
+class FilterDjangoByOrgManaged(FilterDjangoOrganization):
+    """
+    Filter django-filters by organizations managed by user
+    """
+
+    _user_attr = 'organizations_managed'
+
+
+class FilterDjangoByOrgOwned(FilterDjangoOrganization):
+    """
+    Filter django-filters by organizations owned by user
+    """
+
+    _user_attr = 'organizations_owned'
+
+
 class ProtectedAPIMixin(object):
     """
     Contains authentication and permission classes for API views


Test patch:
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index 4cb3b28d..0cf0f208 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -47,6 +47,7 @@
     'drf_yasg',
     'testapp',
     'reversion',
+    'django_filters',
 ]
 
 AUTH_USER_MODEL = 'openwisp_users.User'
diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py
index ede3c4d9..2be056e9 100644
--- a/tests/testapp/__init__.py
+++ b/tests/testapp/__init__.py
@@ -30,3 +30,11 @@ def _create_library(self, **kwargs):
         lib.full_clean()
         lib.save()
         return lib
+
+    def _create_tag(self, **kwargs):
+        options = dict(name='test-tag')
+        options.update(kwargs)
+        tag = self.tag_model(**options)
+        tag.full_clean()
+        tag.save()
+        return tag
diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py
index 079cfc9b..af9a60f2 100644
--- a/tests/testapp/admin.py
+++ b/tests/testapp/admin.py
@@ -7,7 +7,7 @@
     MultitenantRelatedOrgFilter,
 )
 
-from .models import Book, Library, Shelf, Template
+from .models import Book, Library, Shelf, Tag, Template
 
 
 class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin):
@@ -17,8 +17,9 @@ class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin):
 class ShelfAdmin(BaseAdmin):
     list_display = ['name', 'organization']
     list_filter = [MultitenantOrgFilter]
-    fields = ['name', 'organization', 'created', 'modified']
+    fields = ['name', 'organization', 'tags', 'created', 'modified']
     search_fields = ['name']
+    multitenant_shared_relations = ['tags']
 
 
 class ShelfFilter(MultitenantRelatedOrgFilter):
@@ -63,7 +64,12 @@ class TemplateAdmin(BaseAdmin):
     pass
 
 
+class TagAdmin(BaseAdmin):
+    pass
+
+
 admin.site.register(Shelf, ShelfAdmin)
 admin.site.register(Book, BookAdmin)
 admin.site.register(Template, TemplateAdmin)
 admin.site.register(Library)
+admin.site.register(Tag, TagAdmin)
diff --git a/tests/testapp/migrations/0003_multitenancy.py b/tests/testapp/migrations/0003_multitenancy.py
index 3518193a..16483d74 100644
--- a/tests/testapp/migrations/0003_multitenancy.py
+++ b/tests/testapp/migrations/0003_multitenancy.py
@@ -111,4 +111,38 @@ class Migration(migrations.Migration):
                 on_delete=django.db.models.deletion.CASCADE, to='testapp.Shelf'
             ),
         ),
+        migrations.CreateModel(
+            name='Tag',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                ('name', models.CharField(max_length=50)),
+                (
+                    'organization',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=swapper.get_model_name('openwisp_users', 'Organization'),
+                        verbose_name='organization',
+                    ),
+                ),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='shelf',
+            name='tags',
+            field=models.ManyToManyField(blank=True, to='testapp.Tag'),
+        ),
     ]
diff --git a/tests/testapp/migrations/__init__.py b/tests/testapp/migrations/__init__.py
index 0c236fd9..b4476b84 100644
--- a/tests/testapp/migrations/__init__.py
+++ b/tests/testapp/migrations/__init__.py
@@ -21,6 +21,7 @@ def update_administrator_permissions(apps, schema_editor):
             Q(codename__endswith='template')
             | Q(codename__endswith='shelf')
             | Q(codename__endswith='book')
+            | Q(codename__endswith='tag')
         ).values_list('pk', flat=True)
         operator.permissions.add(*permissions)
     except ObjectDoesNotExist:
diff --git a/tests/testapp/models.py b/tests/testapp/models.py
index 7ccc8889..e67b1537 100644
--- a/tests/testapp/models.py
+++ b/tests/testapp/models.py
@@ -26,8 +26,16 @@ def clean(self):
         self._validate_org_relation('template')
 
 
+class Tag(ShareableOrgMixin):
+    name = models.CharField(max_length=50)
+
+    def __str__(self):
+        return self.name
+
+
 class Shelf(ShareableOrgMixin, TimeStampedEditableModel):
     name = models.CharField(_('name'), max_length=64)
+    tags = models.ManyToManyField(Tag, blank=True)
 
     def __str__(self):
         return self.name
diff --git a/tests/testapp/tests/test_filter_classes.py b/tests/testapp/tests/test_filter_classes.py
index 1381a23d..b1769b20 100644
--- a/tests/testapp/tests/test_filter_classes.py
+++ b/tests/testapp/tests/test_filter_classes.py
@@ -6,7 +6,7 @@
 
 from openwisp_users.api.throttling import AuthRateThrottle
 
-from ..models import Book, Library, Shelf
+from ..models import Book, Library, Shelf, Tag
 from .mixins import TestMultitenancyMixin
 
 OrganizationUser = load_model('openwisp_users', 'OrganizationUser')
@@ -20,14 +20,23 @@ def setUp(self):
         self.shelf_model = Shelf
         self.book_model = Book
         self.library_model = Library
+        self.tag_model = Tag
         self._create_org(name='org_a', slug='org_a')
         self._create_org(name='org_b', slug='org_b')
+        self.tag_a = self._create_tag(
+            name='test-tag-a', organization=self._get_org('org_a')
+        )
+        self.tag_b = self._create_tag(
+            name='test-tag-b', organization=self._get_org('org_b')
+        )
         self.shelf_a = self._create_shelf(
             name='test-shelf-a', organization=self._get_org('org_a')
         )
         self.shelf_b = self._create_shelf(
             name='test-shelf-b', organization=self._get_org('org_b')
         )
+        self.shelf_a.tags.add(self.tag_a)
+        self.shelf_b.tags.add(self.tag_b)
         self.book1 = self._create_book(
             name='book1', organization=self._get_org('org_a'), shelf=self.shelf_a
         )
@@ -35,6 +44,32 @@ def setUp(self):
             name='book2', organization=self._get_org('org_a'), shelf=self.shelf_b
         )
 
+    def _assert_django_filters_shelf_options(self, response, shelf_a, shelf_b):
+        self.assertEqual(response.data[0]['id'], str(shelf_a.id))
+        # make sure only correct organization is
+        # visible in the django filters select options
+        self.assertContains(response, 'org_a</option>')
+        # As Shelf API Views use reusable OrganizationFilter classes,
+        # the response should include both organization
+        # and organization slug filter options
+        self.assertContains(
+            response,
+            """
+            <p>
+                <label for="id_organization_slug">Organization slug:</label>
+                <input type="text" name="organization_slug" id="id_organization_slug">
+            </p>
+            """,
+            html=True,
+        )
+        self.assertContains(response, 'test-tag-a</option>')
+        self.assertNotContains(response, str(shelf_b.id))
+        self.assertNotContains(response, 'org_b</option>')
+        self.assertNotContains(response, 'default</option>')
+        self.assertNotContains(response, 'test-shelf-a</option>')
+        self.assertNotContains(response, 'test-shelf-b</option>')
+        self.assertNotContains(response, 'test-tag-b</option>')
+
     def test_browsable_api_filter_manager(self):
         operator = self._get_operator()
         # First user is automatically owner, so created dummy
@@ -241,7 +276,7 @@ def test_get_book_nested_shelf(self):
         )
         token = self._obtain_auth_token(administrator)
         url = reverse('test_book_nested_shelf')
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(8):
             response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token}')
         self.assertEqual(response.status_code, 200)
 
@@ -259,7 +294,7 @@ def test_post_book_nested_shelf(self):
             'author': 'test-auther',
             'organization': org1.pk,
         }
-        with self.assertNumQueries(12):
+        with self.assertNumQueries(13):
             response = self.client.post(
                 url,
                 data,
@@ -277,8 +312,88 @@ def test_shelf_with_read_only_org_field(self):
         self.client.force_login(operator)
         self._create_shelf(name='test-shelf-a', organization=org1)
         path = reverse('test_shelf_list_with_read_only_org')
-        with self.assertNumQueries(4):
+        with self.assertNumQueries(7):
             response = self.client.get(path, {'format': 'api'})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.data[0]['organization'], org1.pk)
         self.assertNotContains(response, 'org1</option>')
+
+    def test_django_filters_superuser(self):
+        admin = self._get_admin()
+        token = self._obtain_auth_token(admin)
+        url = reverse('test_shelf_list_member_view')
+        response = self.client.get(
+            url, {'format': 'api'}, HTTP_AUTHORIZATION=f'Bearer {token}'
+        )
+
+        self.assertEqual(response.data[0]['id'], str(self.shelf_a.id))
+        # superuser can see every filter options
+        self.assertContains(response, str(self.shelf_b.id))
+        self.assertContains(response, 'org_a</option>')
+        self.assertContains(response, 'org_b</option>')
+        self.assertContains(response, 'default</option>')
+        self.assertContains(response, 'id="id_tags" multiple>')
+
+    def test_django_filters_by_field_other_than_organization(self):
+        org1 = self._create_org(name='org1')
+        org2 = self._create_org(name='org2')
+        book_org1 = self._create_book(name='book_o1', organization=org1)
+        book_org2 = self._create_book(name='book_o2', organization=org2)
+        lib1 = self._create_library(name='lib1', book=book_org1)
+        self._create_library(name='lib2', book=book_org2)
+        operator = self._get_operator()
+        self._create_org_user(user=operator, is_admin=True, organization=org1)
+        token = self._obtain_auth_token(operator)
+        url = reverse('test_library_list')
+        response = self.client.get(
+            url, {'format': 'api'}, HTTP_AUTHORIZATION=f'Bearer {token}'
+        )
+        self.assertEqual(response.data[0]['id'], lib1.id)
+        self.assertEqual(len(response.data), 1)
+        # ensure that only the 'books' belonging to 'org1'
+        # are visible in the django-filters select options
+        self.assertContains(response, 'book_o1</option>')
+        self.assertNotContains(response, 'book_o2</option>')
+        self.assertNotContains(response, 'org1</option>')
+        self.assertNotContains(response, 'org2</option>')
+        self.assertNotContains(response, 'default</option>')
+        self.assertNotContains(response, 'lib1</option>')
+        self.assertNotContains(response, 'lib2</option>')
+
+    def test_django_filters_by_org_membership(self):
+        operator = self._get_operator()
+        self._create_org_user(user=operator, organization=self._get_org('org_a'))
+        token = self._obtain_auth_token(operator)
+        url = reverse('test_shelf_list_member_view')
+        response = self.client.get(
+            url, {'format': 'api'}, HTTP_AUTHORIZATION=f'Bearer {token}'
+        )
+
+        self._assert_django_filters_shelf_options(response, self.shelf_a, self.shelf_b)
+
+    def test_django_filters_by_org_managed(self):
+        operator = self._get_operator()
+        self._create_org_user(
+            user=self._get_user(), is_admin=True, organization=self._get_org('org_a')
+        )
+        self._create_org_user(
+            user=operator, is_admin=True, organization=self._get_org('org_a')
+        )
+        token = self._obtain_auth_token(operator)
+        url = reverse('test_shelf_list_manager_view')
+        response = self.client.get(
+            url, {'format': 'api'}, HTTP_AUTHORIZATION=f'Bearer {token}'
+        )
+        self._assert_django_filters_shelf_options(response, self.shelf_a, self.shelf_b)
+
+    def test_django_filters_by_org_owned(self):
+        operator = self._get_operator()
+        self._create_org_user(
+            user=operator, is_admin=True, organization=self._get_org('org_a')
+        )
+        token = self._obtain_auth_token()
+        url = reverse('test_shelf_list_owner_view')
+        response = self.client.get(
+            url, {'format': 'api'}, HTTP_AUTHORIZATION=f'Bearer {token}'
+        )
+        self._assert_django_filters_shelf_options(response, self.shelf_a, self.shelf_b)
diff --git a/tests/testapp/views.py b/tests/testapp/views.py
index 691ea4d6..43f59223 100644
--- a/tests/testapp/views.py
+++ b/tests/testapp/views.py
@@ -1,4 +1,5 @@
 import swapper
+from django_filters import rest_framework as filters
 from rest_framework.generics import (
     ListAPIView,
     ListCreateAPIView,
@@ -8,6 +9,11 @@
 from rest_framework.views import APIView
 
 from openwisp_users.api.authentication import BearerAuthentication
+from openwisp_users.api.filters import (
+    OrganizationManagedFilter,
+    OrganizationMembershipFilter,
+    OrganizationOwnedFilter,
+)
 from openwisp_users.api.mixins import (
     FilterByOrganizationManaged,
     FilterByOrganizationMembership,
@@ -15,6 +21,7 @@
     FilterByParentManaged,
     FilterByParentMembership,
     FilterByParentOwned,
+    FilterDjangoByOrgManaged,
 )
 from openwisp_users.api.permissions import (
     BaseOrganizationPermission,
@@ -99,11 +106,25 @@ class ErrorOrganizationFieldView(BaseGetApiView):
     organization_field = 'error__organization'
 
 
+class ShelfListMemberFilter(OrganizationMembershipFilter):
+    class Meta(OrganizationMembershipFilter.Meta):
+        model = Shelf
+        fields = OrganizationMembershipFilter.Meta.fields + ['tags']
+
+
 class ShelfListMemberView(FilterByOrganizationMembership, ListAPIView):
     authentication_classes = (BearerAuthentication,)
     permission_classes = (IsOrganizationMember,)
     serializer_class = ShelfSerializer
     queryset = Shelf.objects.all()
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = ShelfListMemberFilter
+
+
+class ShelfListManagerFilter(OrganizationManagedFilter):
+    class Meta(OrganizationManagedFilter.Meta):
+        model = Shelf
+        fields = OrganizationManagedFilter.Meta.fields + ['tags']
 
 
 class ShelfListManagerView(FilterByOrganizationManaged, ListAPIView):
@@ -111,6 +132,14 @@ class ShelfListManagerView(FilterByOrganizationManaged, ListAPIView):
     permission_classes = (IsOrganizationManager,)
     serializer_class = ShelfSerializer
     queryset = Shelf.objects.all()
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = ShelfListManagerFilter
+
+
+class ShelfListOwnerFilter(OrganizationOwnedFilter):
+    class Meta(OrganizationOwnedFilter.Meta):
+        model = Shelf
+        fields = OrganizationOwnedFilter.Meta.fields + ['tags']
 
 
 class ShelfListOwnerView(FilterByOrganizationOwned, ListAPIView):
@@ -118,6 +147,8 @@ class ShelfListOwnerView(FilterByOrganizationOwned, ListAPIView):
     permission_classes = (IsOrganizationOwner,)
     serializer_class = ShelfSerializer
     queryset = Shelf.objects.all()
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = ShelfListOwnerFilter
 
 
 class BooksListMemberView(BookOrgMixin, FilterByParentMembership, ListCreateAPIView):
@@ -194,12 +225,20 @@ class TemplateDetailView(FilterByOrganizationManaged, RetrieveUpdateDestroyAPIVi
     queryset = Template.objects.all()
 
 
+class LibraryListFilter(FilterDjangoByOrgManaged):
+    class Meta:
+        model = Library
+        fields = ('book',)
+
+
 class LibraryListCreateView(FilterByOrganizationManaged, ListCreateAPIView):
     serializer_class = LibrarySerializer
     organization_field = 'book__organization'
     authentication_classes = (BearerAuthentication,)
     permission_classes = (IsOrganizationMember,)
     queryset = Library.objects.all()
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = LibraryListFilter
 
 
 class LibraryDetailView(FilterByOrganizationManaged, RetrieveUpdateDestroyAPIView):