From dbef7128c516e552033d638b65e27f2e1fdb3f52 Mon Sep 17 00:00:00 2001 From: Carlos Herrero Date: Tue, 5 Jul 2022 17:13:39 +0200 Subject: [PATCH] LITE-24280: Add support for get_rql_filter_class() in views --- dj_rql/drf/backend.py | 31 ++++++-- setup.py | 2 +- tests/dj_rf/filters.py | 17 +++++ tests/dj_rf/serializers.py | 4 +- tests/dj_rf/urls.py | 6 +- tests/dj_rf/view.py | 15 +++- tests/test_drf/test_common_drf_backend.py | 86 ++++++++++++++++++++--- tests/test_drf/test_dynamic_filter.py | 72 +++++++++++++++++++ tests/test_drf/test_serializers.py | 9 ++- 9 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 tests/test_drf/test_dynamic_filter.py diff --git a/dj_rql/drf/backend.py b/dj_rql/drf/backend.py index ddea49a..2788754 100644 --- a/dj_rql/drf/backend.py +++ b/dj_rql/drf/backend.py @@ -22,10 +22,26 @@ def clear(cls): class RQLFilterBackend(BaseFilterBackend): """ RQL filter backend for DRF GenericAPIViews. - Examples: + Set the backend filter for the ``GenericAPIView`` class-based view, and set the + ``rql_filter_class`` class attribute to the ``RQLFilterClass`` to use: + + .. code-block:: python + class ViewSet(mixins.ListModelMixin, GenericViewSet): filter_backends = (RQLFilterBackend,) rql_filter_class = ModelFilterClass + + Yo can also add a ``get_rql_filter_class()`` method to the view to get the filter class: + + .. code-block:: python + + class ViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet): + filter_backends = (RQLFilterBackend,) + + def get_rql_filter_class(self): + if self.action == 'retrieve': + return ModelDetailFilterClass + return ModelFilterClass """ OPENAPI_RETRIEVE_SPECIFICATION = False @@ -85,6 +101,8 @@ def get_schema_operation_parameters(self, view): @staticmethod def get_filter_class(view): + if hasattr(view, 'get_rql_filter_class') and callable(view.get_rql_filter_class): + return view.get_rql_filter_class() return getattr(view, 'rql_filter_class', None) @classmethod @@ -93,14 +111,14 @@ def get_query(cls, filter_instance, request, view): @classmethod def _get_or_init_cache(cls, filter_class, view): - qual_name = cls._get_filter_cls_qual_name(view) + qual_name = cls._get_filter_cls_qual_name(view, filter_class) return cls._CACHES.setdefault( qual_name, filter_class.QUERIES_CACHE_BACKEND(int(filter_class.QUERIES_CACHE_SIZE)), ) @classmethod def _get_filter_instance(cls, filter_class, queryset, view): - qual_name = cls._get_filter_cls_qual_name(view) + qual_name = cls._get_filter_cls_qual_name(view, filter_class) filter_instance = _FilterClassCache.CACHE.get(qual_name) if filter_instance: @@ -111,5 +129,8 @@ def _get_filter_instance(cls, filter_class, queryset, view): return filter_instance @staticmethod - def _get_filter_cls_qual_name(view): - return '{0}.{1}'.format(view.__class__.__module__, view.__class__.__name__) + def _get_filter_cls_qual_name(view, filter_class): + return '{0}.{1}+{2}.{3}'.format( + view.__class__.__module__, view.__class__.__name__, + filter_class.__module__, filter_class.__name__, + ) diff --git a/setup.py b/setup.py index 27dbd68..29e074e 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def read_file(name): include_package_data=True, install_requires=read_file('requirements/dev.txt').splitlines(), tests_require=read_file('requirements/test.txt').splitlines(), - setup_requires=['setuptools_scm', 'pytest-runner', 'wheel'], + setup_requires=['setuptools_scm<7', 'pytest-runner', 'wheel'], extras_require={ 'drf': read_file('requirements/extra.txt').splitlines(), }, diff --git a/tests/dj_rf/filters.py b/tests/dj_rf/filters.py index e4a30a7..39d7809 100644 --- a/tests/dj_rf/filters.py +++ b/tests/dj_rf/filters.py @@ -1,6 +1,8 @@ # # Copyright © 2022 Ingram Micro Inc. All rights reserved. # +from copy import deepcopy + from cachetools import LFUCache, LRUCache from dj_rql.fields import SelectField @@ -48,6 +50,7 @@ class BooksFilterClass(RQLFilterClass): 'openapi': { 'required': True, }, + 'hidden': True, }, { 'filter': 'author__email', 'search': True, @@ -67,6 +70,7 @@ class BooksFilterClass(RQLFilterClass): 'filters': AUTHOR_FILTERS, 'distinct': True, 'qs': SR('author', 'author__publisher'), + 'hidden': True, }, { 'namespace': 'page', 'source': 'pages', @@ -89,6 +93,7 @@ class BooksFilterClass(RQLFilterClass): 'filter': 'amazon_rating', 'lookups': {FilterLookups.GE, FilterLookups.LT}, 'null_values': {'random'}, + 'hidden': True, }, { 'filter': 'url', 'source': 'publishing_url', @@ -196,3 +201,15 @@ class SelectBooksFilterClass(BooksFilterClass): SELECT = True QUERIES_CACHE_BACKEND = LRUCache QUERIES_CACHE_SIZE = 100 + + +class SelectDetailedBooksFilterClass(SelectBooksFilterClass): + + def __make_filters(): + result = deepcopy(BooksFilterClass.FILTERS) + result[4]['hidden'] = False # status + result[7]['hidden'] = False # author + result[12]['hidden'] = False # amazon_rating + return result + + FILTERS = __make_filters() diff --git a/tests/dj_rf/serializers.py b/tests/dj_rf/serializers.py index 746a3b4..881fcf8 100644 --- a/tests/dj_rf/serializers.py +++ b/tests/dj_rf/serializers.py @@ -1,5 +1,5 @@ # -# Copyright © 2021 Ingram Micro Inc. All rights reserved. +# Copyright © 2022 Ingram Micro Inc. All rights reserved. # from dj_rql.drf.serializers import RQLMixin @@ -68,6 +68,8 @@ class Meta: 'author_ref', # One level reference field (FK) 'author', # Deep nested fields (FK) 'pages', # List of backrefs + 'status', + 'amazon_rating', ) def get_author(self, obj): diff --git a/tests/dj_rf/urls.py b/tests/dj_rf/urls.py index 7e88560..f1c6c53 100644 --- a/tests/dj_rf/urls.py +++ b/tests/dj_rf/urls.py @@ -1,5 +1,5 @@ # -# Copyright © 2021 Ingram Micro Inc. All rights reserved. +# Copyright © 2022 Ingram Micro Inc. All rights reserved. # from django.conf.urls import include @@ -8,7 +8,8 @@ from rest_framework.routers import SimpleRouter from tests.dj_rf.view import ( - AutoViewSet, DRFViewSet, DjangoFiltersViewSet, NoFilterClsViewSet, SelectViewSet, + AutoViewSet, DRFViewSet, DjangoFiltersViewSet, DynamicFilterClsViewSet, NoFilterClsViewSet, + SelectViewSet, ) @@ -18,6 +19,7 @@ router.register(r'select', SelectViewSet, basename='select') router.register(r'nofiltercls', NoFilterClsViewSet, basename='nofiltercls') router.register(r'auto', AutoViewSet, basename='auto') +router.register(r'dynamicfiltercls', DynamicFilterClsViewSet, basename='dynamicfiltercls') urlpatterns = [ re_path(r'^', include(router.urls)), diff --git a/tests/dj_rf/view.py b/tests/dj_rf/view.py index f6a4ef9..de2f9b2 100644 --- a/tests/dj_rf/view.py +++ b/tests/dj_rf/view.py @@ -1,5 +1,5 @@ # -# Copyright © 2021 Ingram Micro Inc. All rights reserved. +# Copyright © 2022 Ingram Micro Inc. All rights reserved. # from dj_rql.drf.backend import RQLFilterBackend @@ -14,7 +14,9 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from tests.dj_rf.filters import BooksFilterClass, SelectBooksFilterClass +from tests.dj_rf.filters import ( + BooksFilterClass, SelectBooksFilterClass, SelectDetailedBooksFilterClass, +) from tests.dj_rf.models import Book from tests.dj_rf.serializers import BookSerializer, SelectBookSerializer @@ -56,6 +58,15 @@ class SelectViewSet(mixins.RetrieveModelMixin, DRFViewSet): rql_filter_class = SelectBooksFilterClass +class DynamicFilterClsViewSet(mixins.RetrieveModelMixin, DRFViewSet): + serializer_class = SelectBookSerializer + + def get_rql_filter_class(self): + if self.action == 'retrieve': + return SelectDetailedBooksFilterClass + return SelectBooksFilterClass + + class NoFilterClsViewSet(DRFViewSet): rql_filter_class = None diff --git a/tests/test_drf/test_common_drf_backend.py b/tests/test_drf/test_common_drf_backend.py index 7c104b0..047e6df 100644 --- a/tests/test_drf/test_common_drf_backend.py +++ b/tests/test_drf/test_common_drf_backend.py @@ -1,5 +1,5 @@ # -# Copyright © 2021 Ingram Micro Inc. All rights reserved. +# Copyright © 2022 Ingram Micro Inc. All rights reserved. # from cachetools import LFUCache, LRUCache @@ -12,7 +12,7 @@ import pytest from rest_framework.reverse import reverse -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND from tests.dj_rf.models import Book @@ -103,7 +103,7 @@ def test_filter_cls_cache(api_client, clear_cache): response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=F')) assert response.data == [{'id': books[0].pk}] - expected_cache_key = 'tests.dj_rf.view.DRFViewSet' + expected_cache_key = 'tests.dj_rf.view.DRFViewSet+tests.dj_rf.filters.BooksFilterClass' assert expected_cache_key in _FilterClassCache.CACHE cache_item_id = id(_FilterClassCache.CACHE[expected_cache_key]) @@ -117,6 +117,42 @@ def test_filter_cls_cache(api_client, clear_cache): assert _FilterClassCache.CACHE == {} +@pytest.mark.django_db +def test_dynamic_filter_cls_cache(api_client, clear_cache): + books = [ + Book.objects.create(title='F'), + Book.objects.create(title='G'), + ] + + list_cache_key = '{0}+{1}'.format( + 'tests.dj_rf.view.DynamicFilterClsViewSet', + 'tests.dj_rf.filters.SelectBooksFilterClass', + ) + detail_cache_key = '{0}+{1}'.format( + 'tests.dj_rf.view.DynamicFilterClsViewSet', + 'tests.dj_rf.filters.SelectDetailedBooksFilterClass', + ) + + assert _FilterClassCache.CACHE == {} + + api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=F')) + assert list_cache_key in _FilterClassCache.CACHE + + list_cache_item_id = id(_FilterClassCache.CACHE[list_cache_key]) + api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=G')) + assert len(_FilterClassCache.CACHE) == 1 + assert id(_FilterClassCache.CACHE[list_cache_key]) == list_cache_item_id + + api_client.get(reverse('dynamicfiltercls-detail', [books[0].pk])) + assert len(_FilterClassCache.CACHE) == 2 + assert detail_cache_key in _FilterClassCache.CACHE + + detail_cache_item_id = id(_FilterClassCache.CACHE[detail_cache_key]) + api_client.get(reverse('dynamicfiltercls-detail', [books[1].pk])) + assert len(_FilterClassCache.CACHE) == 2 + assert id(_FilterClassCache.CACHE[detail_cache_key]) == detail_cache_item_id + + @pytest.mark.django_db def test_query_cache(api_client, clear_cache, django_assert_num_queries): books = [ @@ -136,13 +172,45 @@ def test_query_cache(api_client, clear_cache, django_assert_num_queries): assert response.status_code == HTTP_200_OK assert 'id' not in response.data[0] + response = api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=F')) + assert response.data[0]['id'] == books[0].pk + + response = api_client.get(reverse('dynamicfiltercls-list') + '?select(author)') + assert len(response.data) == 2 + + response = api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=X')) + assert response.data == [] + + response = api_client.get(reverse('dynamicfiltercls-detail', [books[0].pk])) + assert response.data['id'] == books[0].pk + + response = api_client.get(reverse('dynamicfiltercls-detail', ['non-exists'])) + assert response.status_code == HTTP_404_NOT_FOUND + caches = RQLFilterBackend._CACHES - assert isinstance(caches['tests.dj_rf.view.DRFViewSet'], LFUCache) - assert caches['tests.dj_rf.view.DRFViewSet'].currsize == 2 - assert caches['tests.dj_rf.view.DRFViewSet'].maxsize == 20 - assert isinstance(caches['tests.dj_rf.view.SelectViewSet'], LRUCache) - assert caches['tests.dj_rf.view.SelectViewSet'].currsize == 1 - assert caches['tests.dj_rf.view.SelectViewSet'].maxsize == 100 + cache = caches['tests.dj_rf.view.DRFViewSet+tests.dj_rf.filters.BooksFilterClass'] + assert isinstance(cache, LFUCache) + assert cache.currsize == 2 + assert cache.maxsize == 20 + + cache = caches['tests.dj_rf.view.SelectViewSet+tests.dj_rf.filters.SelectBooksFilterClass'] + assert isinstance(cache, LRUCache) + assert cache.currsize == 1 + assert cache.maxsize == 100 + + cache = caches[ + 'tests.dj_rf.view.DynamicFilterClsViewSet' + '+tests.dj_rf.filters.SelectBooksFilterClass' + ] + assert isinstance(cache, LRUCache) + assert cache.currsize == 3 + + cache = caches[ + 'tests.dj_rf.view.DynamicFilterClsViewSet' + '+tests.dj_rf.filters.SelectDetailedBooksFilterClass' + ] + assert isinstance(cache, LRUCache) + assert cache.currsize == 1 @pytest.mark.django_db diff --git a/tests/test_drf/test_dynamic_filter.py b/tests/test_drf/test_dynamic_filter.py new file mode 100644 index 0000000..a622ab3 --- /dev/null +++ b/tests/test_drf/test_dynamic_filter.py @@ -0,0 +1,72 @@ +# +# Copyright © 2022 Ingram Micro Inc. All rights reserved. +# + +import pytest + +from rest_framework.reverse import reverse +from rest_framework.status import HTTP_200_OK + +from tests.dj_rf.models import Author, Book, Publisher + + +@pytest.mark.django_db +def test_detail_default(api_client, clear_cache): + publisher = Publisher.objects.create(name='publisher') + author = Author.objects.create(name='auth', publisher=publisher) + book = Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0) + + response = api_client.get(reverse('dynamicfiltercls-detail', [book.pk])) + + assert response.status_code == HTTP_200_OK + assert 'author' in response.data + assert 'status' in response.data + assert 'amazon_rating' in response.data + + +@pytest.mark.django_db +def test_detail_exclude_fields(api_client, clear_cache): + publisher = Publisher.objects.create(name='publisher') + author = Author.objects.create(name='auth', publisher=publisher) + book = Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0) + + response = api_client.get( + reverse('dynamicfiltercls-detail', [book.pk]) + + '?select(-author,-status,-amazon_rating)', + ) + + assert response.status_code == HTTP_200_OK + assert 'author' not in response.data + assert 'status' not in response.data + assert 'amazon_rating' not in response.data + + +@pytest.mark.django_db +def test_list_default(api_client, clear_cache): + publisher = Publisher.objects.create(name='publisher') + author = Author.objects.create(name='auth', publisher=publisher) + Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0) + + response = api_client.get(reverse('dynamicfiltercls-list')) + + assert response.status_code == HTTP_200_OK + assert 'author' not in response.data[0] + assert 'status' not in response.data[0] + assert 'amazon_rating' not in response.data[0] + + +@pytest.mark.django_db +def test_list_include_fields(api_client, clear_cache): + publisher = Publisher.objects.create(name='publisher') + author = Author.objects.create(name='auth', publisher=publisher) + Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0) + + response = api_client.get( + reverse('dynamicfiltercls-list') + + '?select(author,status,amazon_rating)', + ) + + assert response.status_code == HTTP_200_OK + assert 'author' in response.data[0] + assert 'status' in response.data[0] + assert 'amazon_rating' in response.data[0] diff --git a/tests/test_drf/test_serializers.py b/tests/test_drf/test_serializers.py index 0f505fe..3f6cf1b 100644 --- a/tests/test_drf/test_serializers.py +++ b/tests/test_drf/test_serializers.py @@ -1,5 +1,5 @@ # -# Copyright © 2020 Ingram Micro Inc. All rights reserved. +# Copyright © 2022 Ingram Micro Inc. All rights reserved. # from collections import OrderedDict @@ -14,7 +14,7 @@ def test_select_complex(): publisher = Publisher.objects.create(name='publisher') author = Author.objects.create(name='auth', publisher=publisher) - book = Book.objects.create(author=author) + book = Book.objects.create(author=author, status='planning', amazon_rating=5.0) page = Page.objects.create(book=book, number=1, content='text') select = OrderedDict() @@ -50,7 +50,10 @@ class Request: }, 'pages': [{ 'id': str(page.uuid), - }]} == data + }], + 'status': book.status, + 'amazon_rating': book.amazon_rating, + } == data @pytest.mark.django_db