From 1e9ece0f9353515265da9b6266dc4b39775a0257 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Mon, 8 Oct 2012 22:00:55 +0200 Subject: [PATCH 01/23] First attempt at adding filter support. The filter support uses django-filter to work its magic. --- requirements.txt | 1 + rest_framework/generics.py | 35 ++++++- rest_framework/mixins.py | 2 +- rest_framework/tests/filterset.py | 160 +++++++++++++++++++++++++++++ rest_framework/tests/models.py | 7 ++ rest_framework/tests/pagination.py | 69 ++++++++++++- 6 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 rest_framework/tests/filterset.py diff --git a/requirements.txt b/requirements.txt index 730c1d07a..b37b61851 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ Django>=1.3 +-e git+https://github.com/alex/django-filter.git#egg=django-filter diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 59739d010..3b2bea3bc 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -1,12 +1,12 @@ """ -Generic views that provide commmonly needed behaviour. +Generic views that provide commonly needed behaviour. """ from rest_framework import views, mixins from rest_framework.settings import api_settings from django.views.generic.detail import SingleObjectMixin from django.views.generic.list import MultipleObjectMixin - +import django_filters ### Base classes for the generic views ### @@ -58,6 +58,37 @@ class MultipleObjectBaseView(MultipleObjectMixin, BaseView): pagination_serializer_class = api_settings.PAGINATION_SERIALIZER paginate_by = api_settings.PAGINATE_BY + filter_class = None + filter_fields = None + + def get_filter_class(self): + """ + Return the django-filters `FilterSet` used to filter the queryset. + """ + if self.filter_class: + return self.filter_class + + if self.filter_fields: + class AutoFilterSet(django_filters.FilterSet): + class Meta: + model = self.model + fields = self.filter_fields + return AutoFilterSet + + return None + + def filter_queryset(self, queryset): + filter_class = self.get_filter_class() + + if filter_class: + assert issubclass(filter_class.Meta.model, self.model), \ + "%s is not a subclass of %s" % (filter_class.Meta.model, self.model) + return filter_class(self.request.GET, queryset=queryset) + + return queryset + + def get_filtered_queryset(self): + return self.filter_queryset(self.get_queryset()) def get_pagination_serializer_class(self): """ diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 29153e182..04626fb0e 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -33,7 +33,7 @@ class ListModelMixin(object): empty_error = u"Empty list and '%(class_name)s.allow_empty' is False." def list(self, request, *args, **kwargs): - self.object_list = self.get_queryset() + self.object_list = self.get_filtered_queryset() # Default is to allow empty querysets. This can be altered by setting # `.allow_empty = False`, to raise 404 errors on empty querysets. diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py new file mode 100644 index 000000000..8c857f3f9 --- /dev/null +++ b/rest_framework/tests/filterset.py @@ -0,0 +1,160 @@ +import datetime +from django.test import TestCase +from django.test.client import RequestFactory +from rest_framework import generics, status +from rest_framework.tests.models import FilterableItem, BasicModel +import django_filters + +factory = RequestFactory() + +# Basic filter on a list view. +class FilterFieldsRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_fields = ['decimal', 'date'] + + +# These class are used to test a filter class. +class SeveralFieldsFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + decimal = django_filters.NumberFilter(lookup_type='lt') + date = django_filters.DateFilter(lookup_type='gt') + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterClassRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter + + +# These classes are used to test a misconfigured filter class. +class MisconfiguredFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + class Meta: + model = BasicModel + fields = ['text'] + + +class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = MisconfiguredFilter + + +class IntegrationTestFiltering(TestCase): + """ + Integration tests for filtered list views. + """ + + def setUp(self): + """ + Create 10 FilterableItem instances. + """ + base_data = ('a', 0.25, datetime.date(2012, 10, 8)) + for i in range(10): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + for obj in self.objects.all() + ] + + def test_get_filtered_fields_root_view(self): + """ + GET requests to paginated ListCreateAPIView should return paginated results. + """ + view = FilterFieldsRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data, self.data) + + # Tests that the decimal filter works. + search_decimal = 2.25 + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['decimal'] == search_decimal ] + self.assertEquals(response.data, expected_data) + + # Tests that the date filter works. + search_date = datetime.date(2012, 9, 22) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-09-22' + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] == search_date ] + self.assertEquals(response.data, expected_data) + + def test_get_filtered_class_root_view(self): + """ + GET requests to filtered ListCreateAPIView that have a filter_class set + should return filtered results. + """ + view = FilterClassRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data, self.data) + + # Tests that the decimal filter set with 'lt' in the filter class works. + search_decimal = 4.25 + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['decimal'] < search_decimal ] + self.assertEquals(response.data, expected_data) + + # Tests that the date filter set with 'gt' in the filter class works. + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-10-02' + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] > search_date ] + self.assertEquals(response.data, expected_data) + + # Tests that the text filter set with 'icontains' in the filter class works. + search_text = 'ff' + request = factory.get('/?text=%s' % search_text) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if search_text in f['text'].lower() ] + self.assertEquals(response.data, expected_data) + + # Tests that multiple filters works. + search_decimal = 5.25 + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] > search_date and + f['decimal'] < search_decimal ] + self.assertEquals(response.data, expected_data) + + def test_incorrectly_configured_filter(self): + """ + An error should be displayed when the filter class is misconfigured. + """ + view = IncorrectlyConfiguredRootView.as_view() + + request = factory.get('/') + self.assertRaises(AssertionError, view, request) + + # TODO Return 400 filter paramater requested that hasn't been configured. + def test_bad_request(self): + """ + GET requests with filters that aren't configured should return 400. + """ + view = FilterFieldsRootView.as_view() + + search_integer = 10 + request = factory.get('/?integer=%s' % search_integer) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 6a758f0ce..780c9dba7 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -85,6 +85,13 @@ class Bookmark(RESTFrameworkModel): tags = GenericRelation(TaggedItem) +# Model to test filtering. +class FilterableItem(RESTFrameworkModel): + text = models.CharField(max_length=100) + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + # Model for regression test for #285 class Comment(RESTFrameworkModel): diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index a939c9ef5..729bbfc2f 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -1,8 +1,10 @@ +import datetime from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory from rest_framework import generics, status, pagination -from rest_framework.tests.models import BasicModel +from rest_framework.tests.models import BasicModel, FilterableItem +import django_filters factory = RequestFactory() @@ -15,6 +17,19 @@ class RootView(generics.ListCreateAPIView): paginate_by = 10 +class DecimalFilter(django_filters.FilterSet): + decimal = django_filters.NumberFilter(lookup_type='lt') + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterFieldsRootView(generics.ListCreateAPIView): + model = FilterableItem + paginate_by = 10 + filter_class = DecimalFilter + + class IntegrationTestPagination(TestCase): """ Integration tests for paginated list views. @@ -22,7 +37,7 @@ class IntegrationTestPagination(TestCase): def setUp(self): """ - Create 26 BasicModel intances. + Create 26 BasicModel instances. """ for char in 'abcdefghijklmnopqrstuvwxyz': BasicModel(text=char * 3).save() @@ -61,6 +76,56 @@ class IntegrationTestPagination(TestCase): self.assertEquals(response.data['next'], None) self.assertNotEquals(response.data['previous'], None) +class IntegrationTestPaginationAndFiltering(TestCase): + + def setUp(self): + """ + Create 50 FilterableItem instances. + """ + base_data = ('a', 0.25, datetime.date(2012, 10, 8)) + for i in range(26): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + for obj in self.objects.all() + ] + self.view = FilterFieldsRootView.as_view() + + def test_get_paginated_filtered_root_view(self): + """ + GET requests to paginated filtered ListCreateAPIView should return + paginated results. The next and previous links should preserve the + filtered parameters. + """ + request = factory.get('/?decimal=15.20') + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[:10]) + self.assertNotEquals(response.data['next'], None) + self.assertEquals(response.data['previous'], None) + + request = factory.get(response.data['next']) + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[10:15]) + self.assertEquals(response.data['next'], None) + self.assertNotEquals(response.data['previous'], None) + + request = factory.get(response.data['previous']) + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[:10]) + self.assertNotEquals(response.data['next'], None) + self.assertEquals(response.data['previous'], None) + class UnitTestPagination(TestCase): """ From 692203f933b77cc3b18e15434002169b642fbd84 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Tue, 9 Oct 2012 08:22:00 +0200 Subject: [PATCH 02/23] Check for 200 status when unknown filter requested. This changes the test from the failing checking for status 400. See discussion here: https://github.com/tomchristie/django-rest-framework/pull/169#issuecomment-9240480 --- rest_framework/tests/filterset.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 8c857f3f9..b21abacb8 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -147,14 +147,13 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/') self.assertRaises(AssertionError, view, request) - # TODO Return 400 filter paramater requested that hasn't been configured. - def test_bad_request(self): + def test_unknown_filter(self): """ - GET requests with filters that aren't configured should return 400. + GET requests with filters that aren't configured should return 200. """ view = FilterFieldsRootView.as_view() search_integer = 10 request = factory.get('/?integer=%s' % search_integer) response = view(request).render() - self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) \ No newline at end of file + self.assertEquals(response.status_code, status.HTTP_200_OK) \ No newline at end of file From e295f616ec2cfee9c24b22d4be1a605a93d9544d Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 11:32:51 +0200 Subject: [PATCH 03/23] Fix small PEP8 problem. --- rest_framework/tests/pagination.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 729bbfc2f..054b7ee33 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -76,6 +76,7 @@ class IntegrationTestPagination(TestCase): self.assertEquals(response.data['next'], None) self.assertNotEquals(response.data['previous'], None) + class IntegrationTestPaginationAndFiltering(TestCase): def setUp(self): From 6fbd411254089c86baca65b08a89d239e5b804a9 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 11:35:00 +0200 Subject: [PATCH 04/23] Make query filters work with pagination. --- rest_framework/pagination.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 131718fd7..616c76749 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -14,6 +14,9 @@ class NextPageField(serializers.Field): request = self.context.get('request') relative_url = '?page=%d' % page if request: + for field, value in request.QUERY_PARAMS.iteritems(): + if field != 'page': + relative_url += '&%s=%s' % (field, value) return request.build_absolute_uri(relative_url) return relative_url @@ -29,7 +32,10 @@ class PreviousPageField(serializers.Field): request = self.context.get('request') relative_url = '?page=%d' % page if request: - return request.build_absolute_uri('?page=%d' % page) + for field, value in request.QUERY_PARAMS.iteritems(): + if field != 'page': + relative_url += '&%s=%s' % (field, value) + return request.build_absolute_uri(relative_url) return relative_url From 5454162b0419ab6564f37ea56b1852d686b0a11e Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 11:39:23 +0200 Subject: [PATCH 05/23] Define 'page' query field name in one place. --- rest_framework/pagination.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 616c76749..c77a10051 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -3,7 +3,11 @@ from rest_framework import serializers # TODO: Support URLconf kwarg-style paging -class NextPageField(serializers.Field): +class PageField(serializers.Field): + page_field = 'page' + + +class NextPageField(PageField): """ Field that returns a link to the next page in paginated results. """ @@ -12,16 +16,16 @@ class NextPageField(serializers.Field): return None page = value.next_page_number() request = self.context.get('request') - relative_url = '?page=%d' % page + relative_url = '?%s=%d' % (self.page_field, page) if request: for field, value in request.QUERY_PARAMS.iteritems(): - if field != 'page': + if field != self.page_field: relative_url += '&%s=%s' % (field, value) return request.build_absolute_uri(relative_url) return relative_url -class PreviousPageField(serializers.Field): +class PreviousPageField(PageField): """ Field that returns a link to the previous page in paginated results. """ @@ -30,10 +34,10 @@ class PreviousPageField(serializers.Field): return None page = value.previous_page_number() request = self.context.get('request') - relative_url = '?page=%d' % page + relative_url = '?%s=%d' % (self.page_field, page) if request: for field, value in request.QUERY_PARAMS.iteritems(): - if field != 'page': + if field != self.page_field: relative_url += '&%s=%s' % (field, value) return request.build_absolute_uri(relative_url) return relative_url From 6300334acaef8fe66e03557f089fb335ac861a57 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 11 Oct 2012 11:21:50 +0100 Subject: [PATCH 06/23] Sanitise JSON error messages --- rest_framework/tests/views.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/rest_framework/tests/views.py b/rest_framework/tests/views.py index 3746d7c8c..43365e07a 100644 --- a/rest_framework/tests/views.py +++ b/rest_framework/tests/views.py @@ -1,3 +1,4 @@ +import copy from django.test import TestCase from django.test.client import RequestFactory from rest_framework import status @@ -27,6 +28,17 @@ def basic_view(request): return {'method': 'PUT', 'data': request.DATA} +def sanitise_json_error(error_dict): + """ + Exact contents of JSON error messages depend on the installed version + of json. + """ + ret = copy.copy(error_dict) + chop = len('JSON parse error - No JSON object could be decoded') + ret['detail'] = ret['detail'][:chop] + return ret + + class ClassBasedViewIntegrationTests(TestCase): def setUp(self): self.view = BasicView.as_view() @@ -38,7 +50,7 @@ class ClassBasedViewIntegrationTests(TestCase): 'detail': u'JSON parse error - No JSON object could be decoded' } self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEquals(response.data, expected) + self.assertEquals(sanitise_json_error(response.data), expected) def test_400_parse_error_tunneled_content(self): content = 'f00bar' @@ -53,7 +65,7 @@ class ClassBasedViewIntegrationTests(TestCase): 'detail': u'JSON parse error - No JSON object could be decoded' } self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEquals(response.data, expected) + self.assertEquals(sanitise_json_error(response.data), expected) class FunctionBasedViewIntegrationTests(TestCase): @@ -67,7 +79,7 @@ class FunctionBasedViewIntegrationTests(TestCase): 'detail': u'JSON parse error - No JSON object could be decoded' } self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEquals(response.data, expected) + self.assertEquals(sanitise_json_error(response.data), expected) def test_400_parse_error_tunneled_content(self): content = 'f00bar' @@ -82,4 +94,4 @@ class FunctionBasedViewIntegrationTests(TestCase): 'detail': u'JSON parse error - No JSON object could be decoded' } self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEquals(response.data, expected) + self.assertEquals(sanitise_json_error(response.data), expected) From 6f736a682369e003e4ae4b8d587f9168d4196986 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 13:55:16 +0200 Subject: [PATCH 07/23] Explicitly use Decimal for creating filter test data. This fixes a Travis build failures on python 2.6: https://travis-ci.org/#!/tomchristie/django-rest-framework/builds/2746628 --- rest_framework/tests/filterset.py | 3 ++- rest_framework/tests/pagination.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index b21abacb8..5b2721ff5 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal from django.test import TestCase from django.test.client import RequestFactory from rest_framework import generics, status @@ -50,7 +51,7 @@ class IntegrationTestFiltering(TestCase): """ Create 10 FilterableItem instances. """ - base_data = ('a', 0.25, datetime.date(2012, 10, 8)) + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) for i in range(10): text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. decimal = base_data[1] + i diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 054b7ee33..8c5e6ad72 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory @@ -83,7 +84,7 @@ class IntegrationTestPaginationAndFiltering(TestCase): """ Create 50 FilterableItem instances. """ - base_data = ('a', 0.25, datetime.date(2012, 10, 8)) + base_data = ('a', Decimal(0.25), datetime.date(2012, 10, 8)) for i in range(26): text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. decimal = base_data[1] + i From 1d054f95725e5bec7d4ba9d23717897ef80b7388 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 14:19:29 +0200 Subject: [PATCH 08/23] Use Decimal (properly) everywhere. --- rest_framework/tests/filterset.py | 6 +++--- rest_framework/tests/pagination.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 5b2721ff5..5374eefc8 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -77,7 +77,7 @@ class IntegrationTestFiltering(TestCase): self.assertEquals(response.data, self.data) # Tests that the decimal filter works. - search_decimal = 2.25 + search_decimal = Decimal('2.25') request = factory.get('/?decimal=%s' % search_decimal) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) @@ -106,7 +106,7 @@ class IntegrationTestFiltering(TestCase): self.assertEquals(response.data, self.data) # Tests that the decimal filter set with 'lt' in the filter class works. - search_decimal = 4.25 + search_decimal = Decimal('4.25') request = factory.get('/?decimal=%s' % search_decimal) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) @@ -130,7 +130,7 @@ class IntegrationTestFiltering(TestCase): self.assertEquals(response.data, expected_data) # Tests that multiple filters works. - search_decimal = 5.25 + search_decimal = Decimal('5.25') search_date = datetime.date(2012, 10, 2) request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) response = view(request).render() diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 8c5e6ad72..170515a76 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -84,7 +84,7 @@ class IntegrationTestPaginationAndFiltering(TestCase): """ Create 50 FilterableItem instances. """ - base_data = ('a', Decimal(0.25), datetime.date(2012, 10, 8)) + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) for i in range(26): text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. decimal = base_data[1] + i From c24997df3b943e5d7a3b2e101508e4b79ee82dc4 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 11 Oct 2012 16:39:25 +0200 Subject: [PATCH 09/23] Change django-filter to version that supports Django 1.3. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b37b61851..de7526394 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ Django>=1.3 --e git+https://github.com/alex/django-filter.git#egg=django-filter +-e git+https://github.com/onepercentclub/django-filter.git@django-1.3-compat#egg=django-filter From 9f1aca6a258ed231f2719df14aece541f00243ed Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 1 Nov 2012 16:41:31 +0100 Subject: [PATCH 10/23] Change django-filter requirement to upstream version. This git revision has the django 1.3 compatibility pull request. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index de7526394..48ff9d653 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ Django>=1.3 --e git+https://github.com/onepercentclub/django-filter.git@django-1.3-compat#egg=django-filter +-e git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter From 806bb728bf64f0b610418720cce4efe410c59135 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 1 Nov 2012 20:11:15 +0100 Subject: [PATCH 11/23] Use requirements.txt in Travis 'install'. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 0e177a95a..fa8693a06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ env: install: - pip install $DJANGO + - pip install -r requirements.txt --use-mirrors - export PYTHONPATH=. script: From 40c24a5ab0be49f3b47cbc864489532b48ac9bff Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 1 Nov 2012 20:11:50 +0100 Subject: [PATCH 12/23] Add django-filter to tox.ini. --- tox.ini | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tox.ini b/tox.ini index bcfff6729..3596bbdc3 100644 --- a/tox.ini +++ b/tox.ini @@ -8,23 +8,29 @@ commands = {envpython} rest_framework/runtests/runtests.py [testenv:py2.7-django1.5] basepython = python2.7 deps = https://github.com/django/django/zipball/master + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.7-django1.4] basepython = python2.7 deps = django==1.4.1 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.7-django1.3] basepython = python2.7 deps = django==1.3.3 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.5] basepython = python2.6 deps = https://github.com/django/django/zipball/master + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.4] basepython = python2.6 deps = django==1.4.1 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.3] basepython = python2.6 deps = django==1.3.3 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter From 5b399a844bfd07e94084bff84bbd9f98dbfef139 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 1 Nov 2012 20:28:15 +0100 Subject: [PATCH 13/23] Add django-filter (and django) requirement to setup.py. --- setup.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 26d072837..601bb65b8 100755 --- a/setup.py +++ b/setup.py @@ -63,7 +63,13 @@ setup( packages=get_packages('rest_framework'), package_data=get_package_data('rest_framework'), test_suite='rest_framework.runtests.runtests.main', - install_requires=[], + install_requires=[ + 'Django>=1.3.0', + 'django-filter', + ], + dependency_links = [ + 'git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter', + ], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', From 01564fb1e5727134d2ceb4b3ab79e013af1b4807 Mon Sep 17 00:00:00 2001 From: Ben Konrath Date: Thu, 1 Nov 2012 21:57:14 +0100 Subject: [PATCH 14/23] Revert "Add django-filter (and django) requirement to setup.py." This reverts commit 5b399a844bfd07e94084bff84bbd9f98dbfef139. This has been reverted because it's not working properly. --- setup.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 601bb65b8..26d072837 100755 --- a/setup.py +++ b/setup.py @@ -63,13 +63,7 @@ setup( packages=get_packages('rest_framework'), package_data=get_package_data('rest_framework'), test_suite='rest_framework.runtests.runtests.main', - install_requires=[ - 'Django>=1.3.0', - 'django-filter', - ], - dependency_links = [ - 'git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter', - ], + install_requires=[], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', From 47b534a13e42d498629bf9522225633122c563d5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 7 Nov 2012 21:07:24 +0000 Subject: [PATCH 15/23] Make filtering optional, and pluggable. --- docs/api-guide/filtering.md | 114 ++++++++++++++++++ rest_framework/compat.py | 34 ++---- rest_framework/filters.py | 52 ++++++++ rest_framework/generics.py | 33 +---- rest_framework/pagination.py | 19 +-- rest_framework/settings.py | 2 + rest_framework/templatetags/rest_framework.py | 27 ++--- rest_framework/tests/filterset.py | 71 ++++++----- rest_framework/tests/pagination.py | 25 ++-- rest_framework/tests/response.py | 6 - rest_framework/utils/__init__.py | 1 - 11 files changed, 251 insertions(+), 133 deletions(-) create mode 100644 docs/api-guide/filtering.md create mode 100644 rest_framework/filters.py diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md new file mode 100644 index 000000000..7f6a9c970 --- /dev/null +++ b/docs/api-guide/filtering.md @@ -0,0 +1,114 @@ +# Filtering + +> The root QuerySet provided by the Manager describes all objects in the database table. Usually, though, you'll need to select only a subset of the complete set of objects. +> +> — [Django documentation][cite] + +The default behavior of REST framework's generic list views is to return the entire queryset for a model manager. Often you will want your API to restrict the items that are returned by the queryset. + +The simplest way to filter the queryset of any view that subclasses `MultipleObjectAPIView` is to override the `.get_queryset()` method. + +Overriding this method allows you to customize the queryset returned by the view in a number of different ways. + +## Filtering against the current user + +You might want to filter the queryset to ensure that only results relevant to the currently authenticated user making the request are returned. + +You can do so by filtering based on the value of `request.user`. + +For example: + + class PurchaseList(generics.ListAPIView) + model = Purchase + serializer_class = PurchaseSerializer + + def get_queryset(self): + """ + This view should return a list of all the purchases + for the currently authenticated user. + """ + user = self.request.user + return Purchase.objects.filter(purchaser=user) + + +## Filtering against the URL + +Another style of filtering might involve restricting the queryset based on some part of the URL. + +For example if your URL config contained an entry like this: + + url('^purchases/(?P.+)/$', PurchaseList.as_view()), + +You could then write a view that returned a purchase queryset filtered by the username portion of the URL: + + class PurchaseList(generics.ListAPIView) + model = Purchase + serializer_class = PurchaseSerializer + + def get_queryset(self): + """ + This view should return a list of all the purchases for + the user as determined by the username portion of the URL. + """ + username = self.kwargs['username'] + return Purchase.objects.filter(purchaser__username=username) + +## Filtering against query parameters + +A final example of filtering the initial queryset would be to determine the initial queryset based on query parameters in the url. + +We can override `.get_queryset()` to deal with URLs such as `http://example.com/api/purchases?username=denvercoder9`, and filter the queryset only if the `username` parameter is included in the URL: + + class PurchaseList(generics.ListAPIView) + model = Purchase + serializer_class = PurchaseSerializer + + def get_queryset(self): + """ + Optionally restricts the returned purchases to a given user, + by filtering against a `username` query parameter in the URL. + """ + queryset = Purchase.objects.all() + username = self.request.QUERY_PARAMS.get('username', None): + if username is not None: + queryset = queryset.filter(purchaser__username=username) + return queryset + +# Generic Filtering + +As well as being able to override the default queryset, REST framework also includes support for generic filtering backends that allow you to easily construct complex filters that can be specified by the client using query parameters. + +REST framework supports pluggable backends to implement filtering, and includes a default implementation which uses the [django-filter] package. + +To use REST framework's default filtering backend, first install `django-filter`. + + pip install -e git+https://github.com/alex/django-filter.git#egg=django-filter + +**Note**: The currently supported version of `django-filter` is the `master` branch. A PyPI release is expected to be coming soon. + +## Specifying filter fields + +**TODO**: Document setting `.filter_fields` on the view. + +## Specifying a FilterSet + +**TODO**: Document setting `.filter_class` on the view. + +**TODO**: Note support for `lookup_type`, double underscore relationship spanning, and ordering. + +# Custom generic filtering + +You can also provide your own generic filtering backend, or write an installable app for other developers to use. + +To do so overide `BaseFilterBackend`, and override the `.filter_queryset(self, request, queryset, view)` method. + +To install the filter, set the `'FILTER_BACKEND'` key in your `'REST_FRAMEWORK'` setting, using the dotted import path of the filter backend class. + +For example: + + REST_FRAMEWORK = { + 'FILTER_BACKEND': 'custom_filters.CustomFilterBackend' + } + +[cite]: https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-specific-objects-with-filters +[django-filter]: https://github.com/alex/django-filter \ No newline at end of file diff --git a/rest_framework/compat.py b/rest_framework/compat.py index b0367a32c..02e50604e 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -5,6 +5,13 @@ versions of django/python, and compatbility wrappers around optional packages. # flake8: noqa import django +# django-filter is optional +try: + import django_filters +except: + django_filters = None + + # cStringIO only if it's available, otherwise StringIO try: import cStringIO as StringIO @@ -348,33 +355,6 @@ except ImportError: yaml = None -import unittest -try: - import unittest.skip -except ImportError: # python < 2.7 - from unittest import TestCase - import functools - - def skip(reason): - # Pasted from py27/lib/unittest/case.py - """ - Unconditionally skip a test. - """ - def decorator(test_item): - if not (isinstance(test_item, type) and issubclass(test_item, TestCase)): - @functools.wraps(test_item) - def skip_wrapper(*args, **kwargs): - pass - test_item = skip_wrapper - - test_item.__unittest_skip__ = True - test_item.__unittest_skip_why__ = reason - return test_item - return decorator - - unittest.skip = skip - - # xml.etree.parse only throws ParseError for python >= 2.7 try: from xml.etree import ParseError as ETParseError diff --git a/rest_framework/filters.py b/rest_framework/filters.py new file mode 100644 index 000000000..b972e82a1 --- /dev/null +++ b/rest_framework/filters.py @@ -0,0 +1,52 @@ +from rest_framework.compat import django_filters + + +class BaseFilterBackend(object): + """ + A base class from which all filter backend classes should inherit. + """ + + def filter_queryset(self, request, queryset, view): + """ + Return a filtered queryset. + """ + raise NotImplementedError(".filter_queryset() must be overridden.") + + +class DjangoFilterBackend(BaseFilterBackend): + """ + A filter backend that uses django-filter. + """ + + def get_filter_class(self, view): + """ + Return the django-filters `FilterSet` used to filter the queryset. + """ + filter_class = getattr(view, 'filter_class', None) + filter_fields = getattr(view, 'filter_fields', None) + filter_model = getattr(view, 'model', None) + + if filter_class or filter_fields: + assert django_filters, 'django-filter is not installed' + + if filter_class: + assert issubclass(filter_class.Meta.model, filter_model), \ + '%s is not a subclass of %s' % (filter_class.Meta.model, filter_model) + return filter_class + + if filter_fields: + class AutoFilterSet(django_filters.FilterSet): + class Meta: + model = filter_model + fields = filter_fields + return AutoFilterSet + + return None + + def filter_queryset(self, request, queryset, view): + filter_class = self.get_filter_class(view) + + if filter_class: + return filter_class(request.GET, queryset=queryset) + + return queryset diff --git a/rest_framework/generics.py b/rest_framework/generics.py index ac02d3da4..ebd06e452 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -6,7 +6,7 @@ from rest_framework import views, mixins from rest_framework.settings import api_settings from django.views.generic.detail import SingleObjectMixin from django.views.generic.list import MultipleObjectMixin -import django_filters + ### Base classes for the generic views ### @@ -58,34 +58,13 @@ class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS paginate_by = api_settings.PAGINATE_BY - filter_class = None - filter_fields = None - - def get_filter_class(self): - """ - Return the django-filters `FilterSet` used to filter the queryset. - """ - if self.filter_class: - return self.filter_class - - if self.filter_fields: - class AutoFilterSet(django_filters.FilterSet): - class Meta: - model = self.model - fields = self.filter_fields - return AutoFilterSet - - return None + filter_backend = api_settings.FILTER_BACKEND def filter_queryset(self, queryset): - filter_class = self.get_filter_class() - - if filter_class: - assert issubclass(filter_class.Meta.model, self.model), \ - "%s is not a subclass of %s" % (filter_class.Meta.model, self.model) - return filter_class(self.request.GET, queryset=queryset) - - return queryset + if not self.filter_backend: + return queryset + backend = self.filter_backend() + return backend.filter_queryset(self.request, queryset, self) def get_filtered_queryset(self): return self.filter_queryset(self.get_queryset()) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index c77a10051..aa54d154a 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from rest_framework.templatetags.rest_framework import replace_query_param # TODO: Support URLconf kwarg-style paging @@ -16,13 +17,8 @@ class NextPageField(PageField): return None page = value.next_page_number() request = self.context.get('request') - relative_url = '?%s=%d' % (self.page_field, page) - if request: - for field, value in request.QUERY_PARAMS.iteritems(): - if field != self.page_field: - relative_url += '&%s=%s' % (field, value) - return request.build_absolute_uri(relative_url) - return relative_url + url = request and request.get_full_path() or '' + return replace_query_param(url, self.page_field, page) class PreviousPageField(PageField): @@ -34,13 +30,8 @@ class PreviousPageField(PageField): return None page = value.previous_page_number() request = self.context.get('request') - relative_url = '?%s=%d' % (self.page_field, page) - if request: - for field, value in request.QUERY_PARAMS.iteritems(): - if field != self.page_field: - relative_url += '&%s=%s' % (field, value) - return request.build_absolute_uri(relative_url) - return relative_url + url = request and request.get_full_path() or '' + return replace_query_param(url, self.page_field, page) class PaginationSerializerOptions(serializers.SerializerOptions): diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 9c40a2144..da647658e 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -55,6 +55,7 @@ DEFAULTS = { 'anon': None, }, 'PAGINATE_BY': None, + 'FILTER_BACKEND': 'rest_framework.filters.DjangoFilterBackend', 'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser', 'UNAUTHENTICATED_TOKEN': None, @@ -79,6 +80,7 @@ IMPORT_STRINGS = ( 'DEFAULT_CONTENT_NEGOTIATION_CLASS', 'DEFAULT_MODEL_SERIALIZER_CLASS', 'DEFAULT_PAGINATION_SERIALIZER_CLASS', + 'FILTER_BACKEND', 'UNAUTHENTICATED_USER', 'UNAUTHENTICATED_TOKEN', ) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index c9b6eb10d..0672ee4f6 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -1,9 +1,9 @@ from django import template from django.core.urlresolvers import reverse -from django.http import QueryDict from django.utils.encoding import force_unicode from django.utils.html import escape from django.utils.safestring import SafeData, mark_safe +from django.http import QueryDict from urlparse import urlsplit, urlunsplit import re import string @@ -11,6 +11,18 @@ import string register = template.Library() +def replace_query_param(url, key, val): + """ + Given a URL and a key/val pair, set or replace an item in the query + parameters of the URL, and return the new URL. + """ + (scheme, netloc, path, query, fragment) = urlsplit(url) + query_dict = QueryDict(query).copy() + query_dict[key] = val + query = query_dict.urlencode() + return urlunsplit((scheme, netloc, path, query, fragment)) + + # Regex for adding classes to html snippets class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])') @@ -31,19 +43,6 @@ hard_coded_bullets_re = re.compile(r'((?:

(?:%s).*?[a-zA-Z].*?

\s*)+)' % '| trailing_empty_content_re = re.compile(r'(?:

(?: |\s|
)*?

\s*)+\Z') -# Helper function for 'add_query_param' -def replace_query_param(url, key, val): - """ - Given a URL and a key/val pair, set or replace an item in the query - parameters of the URL, and return the new URL. - """ - (scheme, netloc, path, query, fragment) = urlsplit(url) - query_dict = QueryDict(query).copy() - query_dict[key] = val - query = query_dict.urlencode() - return urlunsplit((scheme, netloc, path, query, fragment)) - - # And the template tags themselves... @register.simple_tag diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 5374eefc8..6cdea32fe 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -2,44 +2,45 @@ import datetime from decimal import Decimal from django.test import TestCase from django.test.client import RequestFactory +from django.utils import unittest from rest_framework import generics, status +from rest_framework.compat import django_filters from rest_framework.tests.models import FilterableItem, BasicModel -import django_filters factory = RequestFactory() -# Basic filter on a list view. -class FilterFieldsRootView(generics.ListCreateAPIView): - model = FilterableItem - filter_fields = ['decimal', 'date'] - -# These class are used to test a filter class. -class SeveralFieldsFilter(django_filters.FilterSet): - text = django_filters.CharFilter(lookup_type='icontains') - decimal = django_filters.NumberFilter(lookup_type='lt') - date = django_filters.DateFilter(lookup_type='gt') - class Meta: +if django_filters: + # Basic filter on a list view. + class FilterFieldsRootView(generics.ListCreateAPIView): model = FilterableItem - fields = ['text', 'decimal', 'date'] + filter_fields = ['decimal', 'date'] + # These class are used to test a filter class. + class SeveralFieldsFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + decimal = django_filters.NumberFilter(lookup_type='lt') + date = django_filters.DateFilter(lookup_type='gt') -class FilterClassRootView(generics.ListCreateAPIView): - model = FilterableItem - filter_class = SeveralFieldsFilter + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + class FilterClassRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter -# These classes are used to test a misconfigured filter class. -class MisconfiguredFilter(django_filters.FilterSet): - text = django_filters.CharFilter(lookup_type='icontains') - class Meta: - model = BasicModel - fields = ['text'] + # These classes are used to test a misconfigured filter class. + class MisconfiguredFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + class Meta: + model = BasicModel + fields = ['text'] -class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): - model = FilterableItem - filter_class = MisconfiguredFilter + class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = MisconfiguredFilter class IntegrationTestFiltering(TestCase): @@ -64,6 +65,7 @@ class IntegrationTestFiltering(TestCase): for obj in self.objects.all() ] + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_get_filtered_fields_root_view(self): """ GET requests to paginated ListCreateAPIView should return paginated results. @@ -81,7 +83,7 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?decimal=%s' % search_decimal) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if f['decimal'] == search_decimal ] + expected_data = [f for f in self.data if f['decimal'] == search_decimal] self.assertEquals(response.data, expected_data) # Tests that the date filter works. @@ -89,9 +91,10 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?date=%s' % search_date) # search_date str: '2012-09-22' response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if f['date'] == search_date ] + expected_data = [f for f in self.data if f['date'] == search_date] self.assertEquals(response.data, expected_data) + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_get_filtered_class_root_view(self): """ GET requests to filtered ListCreateAPIView that have a filter_class set @@ -110,7 +113,7 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?decimal=%s' % search_decimal) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if f['decimal'] < search_decimal ] + expected_data = [f for f in self.data if f['decimal'] < search_decimal] self.assertEquals(response.data, expected_data) # Tests that the date filter set with 'gt' in the filter class works. @@ -118,7 +121,7 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?date=%s' % search_date) # search_date str: '2012-10-02' response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if f['date'] > search_date ] + expected_data = [f for f in self.data if f['date'] > search_date] self.assertEquals(response.data, expected_data) # Tests that the text filter set with 'icontains' in the filter class works. @@ -126,7 +129,7 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?text=%s' % search_text) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if search_text in f['text'].lower() ] + expected_data = [f for f in self.data if search_text in f['text'].lower()] self.assertEquals(response.data, expected_data) # Tests that multiple filters works. @@ -135,10 +138,11 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) response = view(request).render() self.assertEquals(response.status_code, status.HTTP_200_OK) - expected_data = [ f for f in self.data if f['date'] > search_date and - f['decimal'] < search_decimal ] + expected_data = [f for f in self.data if f['date'] > search_date and + f['decimal'] < search_decimal] self.assertEquals(response.data, expected_data) + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_incorrectly_configured_filter(self): """ An error should be displayed when the filter class is misconfigured. @@ -148,6 +152,7 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/') self.assertRaises(AssertionError, view, request) + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_unknown_filter(self): """ GET requests with filters that aren't configured should return 200. @@ -157,4 +162,4 @@ class IntegrationTestFiltering(TestCase): search_integer = 10 request = factory.get('/?integer=%s' % search_integer) response = view(request).render() - self.assertEquals(response.status_code, status.HTTP_200_OK) \ No newline at end of file + self.assertEquals(response.status_code, status.HTTP_200_OK) diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 7a2134e01..7f8cd5247 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -3,9 +3,10 @@ from decimal import Decimal from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory +from django.utils import unittest from rest_framework import generics, status, pagination +from rest_framework.compat import django_filters from rest_framework.tests.models import BasicModel, FilterableItem -import django_filters factory = RequestFactory() @@ -18,17 +19,18 @@ class RootView(generics.ListCreateAPIView): paginate_by = 10 -class DecimalFilter(django_filters.FilterSet): - decimal = django_filters.NumberFilter(lookup_type='lt') - class Meta: +if django_filters: + class DecimalFilter(django_filters.FilterSet): + decimal = django_filters.NumberFilter(lookup_type='lt') + + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + class FilterFieldsRootView(generics.ListCreateAPIView): model = FilterableItem - fields = ['text', 'decimal', 'date'] - - -class FilterFieldsRootView(generics.ListCreateAPIView): - model = FilterableItem - paginate_by = 10 - filter_class = DecimalFilter + paginate_by = 10 + filter_class = DecimalFilter class IntegrationTestPagination(TestCase): @@ -98,6 +100,7 @@ class IntegrationTestPaginationAndFiltering(TestCase): ] self.view = FilterFieldsRootView.as_view() + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_get_paginated_filtered_root_view(self): """ GET requests to paginated filtered ListCreateAPIView should return diff --git a/rest_framework/tests/response.py b/rest_framework/tests/response.py index 18b6af394..d7b75450c 100644 --- a/rest_framework/tests/response.py +++ b/rest_framework/tests/response.py @@ -131,12 +131,6 @@ class RendererIntegrationTests(TestCase): self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) self.assertEquals(resp.status_code, DUMMYSTATUS) - @unittest.skip('can\'t pass because view is a simple Django view and response is an ImmediateResponse') - def test_unsatisfiable_accept_header_on_request_returns_406_status(self): - """If the Accept header is unsatisfiable we should return a 406 Not Acceptable response.""" - resp = self.client.get('/', HTTP_ACCEPT='foo/bar') - self.assertEquals(resp.status_code, status.HTTP_406_NOT_ACCEPTABLE) - def test_specified_renderer_serializes_content_on_format_query(self): """If a 'format' query is specified, the renderer with the matching format attribute should serialize the response.""" diff --git a/rest_framework/utils/__init__.py b/rest_framework/utils/__init__.py index a59fff453..84fcb5dbb 100644 --- a/rest_framework/utils/__init__.py +++ b/rest_framework/utils/__init__.py @@ -1,7 +1,6 @@ from django.utils.encoding import smart_unicode from django.utils.xmlutils import SimplerXMLGenerator from rest_framework.compat import StringIO - import re import xml.etree.ElementTree as ET From 30799a3955b3b13ae0d40791f1260f05bda438be Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 7 Nov 2012 21:09:26 +0000 Subject: [PATCH 16/23] Simplify NextPageField and PreviousPageField slightly --- rest_framework/pagination.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index aa54d154a..5df3940af 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -4,14 +4,12 @@ from rest_framework.templatetags.rest_framework import replace_query_param # TODO: Support URLconf kwarg-style paging -class PageField(serializers.Field): - page_field = 'page' - - -class NextPageField(PageField): +class NextPageField(serializers.Field): """ Field that returns a link to the next page in paginated results. """ + page_field = 'page' + def to_native(self, value): if not value.has_next(): return None @@ -21,10 +19,12 @@ class NextPageField(PageField): return replace_query_param(url, self.page_field, page) -class PreviousPageField(PageField): +class PreviousPageField(serializers.Field): """ Field that returns a link to the previous page in paginated results. """ + page_field = 'page' + def to_native(self, value): if not value.has_previous(): return None From 9fe317b2caee8e397ae669fb0d99c5fb730f7819 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 7 Nov 2012 21:13:38 +0000 Subject: [PATCH 17/23] Make django-filter optional --- .travis.yml | 1 + optionals.txt | 1 + requirements.txt | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index fa8693a06..800ba2413 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ env: install: - pip install $DJANGO - pip install -r requirements.txt --use-mirrors + - pip install -e git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter - export PYTHONPATH=. script: diff --git a/optionals.txt b/optionals.txt index cf0d8da4c..320cf2163 100644 --- a/optionals.txt +++ b/optionals.txt @@ -1,2 +1,3 @@ markdown>=2.1.0 PyYAML>=3.10 +-e git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter diff --git a/requirements.txt b/requirements.txt index 48ff9d653..730c1d07a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ Django>=1.3 --e git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter From 34c5fb0cc682831822ce77379e8211ec02349897 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 7 Nov 2012 21:28:10 +0000 Subject: [PATCH 18/23] Add filtering into documentation --- docs/api-guide/filtering.md | 8 ++++++++ docs/index.md | 2 ++ docs/template.html | 1 + 3 files changed, 11 insertions(+) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 7f6a9c970..ea1e7d23e 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -1,3 +1,5 @@ + + # Filtering > The root QuerySet provided by the Manager describes all objects in the database table. Usually, though, you'll need to select only a subset of the complete set of objects. @@ -74,6 +76,8 @@ We can override `.get_queryset()` to deal with URLs such as `http://example.com/ queryset = queryset.filter(purchaser__username=username) return queryset +--- + # Generic Filtering As well as being able to override the default queryset, REST framework also includes support for generic filtering backends that allow you to easily construct complex filters that can be specified by the client using query parameters. @@ -96,6 +100,10 @@ To use REST framework's default filtering backend, first install `django-filter` **TODO**: Note support for `lookup_type`, double underscore relationship spanning, and ordering. +**TODO**: Note that overiding `get_queryset()` can be used together with generic filtering + +--- + # Custom generic filtering You can also provide your own generic filtering backend, or write an installable app for other developers to use. diff --git a/docs/index.md b/docs/index.md index 5e0868724..52ea2b75e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -95,6 +95,7 @@ The API guide is your complete reference manual to all the functionality provide * [Authentication][authentication] * [Permissions][permissions] * [Throttling][throttling] +* [Filtering][filtering] * [Pagination][pagination] * [Content negotiation][contentnegotiation] * [Format suffixes][formatsuffixes] @@ -184,6 +185,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [authentication]: api-guide/authentication.md [permissions]: api-guide/permissions.md [throttling]: api-guide/throttling.md +[filtering]: api-guide/filtering.md [pagination]: api-guide/pagination.md [contentnegotiation]: api-guide/content-negotiation.md [formatsuffixes]: api-guide/format-suffixes.md diff --git a/docs/template.html b/docs/template.html index c428dff3a..676a48070 100644 --- a/docs/template.html +++ b/docs/template.html @@ -75,6 +75,7 @@
  • Authentication
  • Permissions
  • Throttling
  • +
  • Filtering
  • Pagination
  • Content negotiation
  • Format suffixes
  • From c78b34d5017a05220bcd623946b4f52cc2d119cd Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 8 Nov 2012 09:10:24 +0000 Subject: [PATCH 19/23] Strict import ordering --- rest_framework/templatetags/rest_framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 0672ee4f6..4e0181ee0 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -1,9 +1,9 @@ from django import template from django.core.urlresolvers import reverse +from django.http import QueryDict from django.utils.encoding import force_unicode from django.utils.html import escape from django.utils.safestring import SafeData, mark_safe -from django.http import QueryDict from urlparse import urlsplit, urlunsplit import re import string From bc6f2a170306fbc1cba3a4e504a908ebc72d54b7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 8 Nov 2012 21:46:53 +0000 Subject: [PATCH 20/23] Make default FILTER_BACKEND = None --- docs/api-guide/filtering.md | 16 +++++++++++----- rest_framework/filters.py | 21 +++++++++++++-------- rest_framework/runtests/settings.py | 1 + rest_framework/settings.py | 9 ++++++++- rest_framework/tests/filterset.py | 5 ++++- rest_framework/tests/pagination.py | 3 ++- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index ea1e7d23e..ca901b039 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -82,24 +82,30 @@ We can override `.get_queryset()` to deal with URLs such as `http://example.com/ As well as being able to override the default queryset, REST framework also includes support for generic filtering backends that allow you to easily construct complex filters that can be specified by the client using query parameters. -REST framework supports pluggable backends to implement filtering, and includes a default implementation which uses the [django-filter] package. +REST framework supports pluggable backends to implement filtering, and provides an implementation which uses the [django-filter] package. To use REST framework's default filtering backend, first install `django-filter`. pip install -e git+https://github.com/alex/django-filter.git#egg=django-filter +You must also set the filter backend to `DjangoFilterBackend` in your settings: + + REST_FRAMEWORK = { + 'FILTER_BACKEND': 'rest_framework.filters.DjangoFilterBackend' + } + **Note**: The currently supported version of `django-filter` is the `master` branch. A PyPI release is expected to be coming soon. -## Specifying filter fields - -**TODO**: Document setting `.filter_fields` on the view. - ## Specifying a FilterSet **TODO**: Document setting `.filter_class` on the view. **TODO**: Note support for `lookup_type`, double underscore relationship spanning, and ordering. +## Specifying filter fields + +**TODO**: Document setting `.filter_fields` on the view. + **TODO**: Note that overiding `get_queryset()` can be used together with generic filtering --- diff --git a/rest_framework/filters.py b/rest_framework/filters.py index b972e82a1..14902a69b 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -17,6 +17,10 @@ class DjangoFilterBackend(BaseFilterBackend): """ A filter backend that uses django-filter. """ + default_filter_set = django_filters.FilterSet + + def __init__(self): + assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed' def get_filter_class(self, view): """ @@ -24,20 +28,21 @@ class DjangoFilterBackend(BaseFilterBackend): """ filter_class = getattr(view, 'filter_class', None) filter_fields = getattr(view, 'filter_fields', None) - filter_model = getattr(view, 'model', None) - - if filter_class or filter_fields: - assert django_filters, 'django-filter is not installed' + view_model = getattr(view, 'model', None) if filter_class: - assert issubclass(filter_class.Meta.model, filter_model), \ - '%s is not a subclass of %s' % (filter_class.Meta.model, filter_model) + filter_model = filter_class.Meta.model + + assert issubclass(filter_model, view_model), \ + 'FilterSet model %s does not match view model %s' % \ + (filter_model, view_model) + return filter_class if filter_fields: - class AutoFilterSet(django_filters.FilterSet): + class AutoFilterSet(self.default_filter_set): class Meta: - model = filter_model + model = view_model fields = filter_fields return AutoFilterSet diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index b48f85e4e..dd5d9dc3c 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -107,6 +107,7 @@ import django if django.VERSION < (1, 3): INSTALLED_APPS += ('staticfiles',) + # If we're running on the Jenkins server we want to archive the coverage reports as XML. import os if os.environ.get('HUDSON_URL', None): diff --git a/rest_framework/settings.py b/rest_framework/settings.py index da647658e..906a7cf6c 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -55,7 +55,7 @@ DEFAULTS = { 'anon': None, }, 'PAGINATE_BY': None, - 'FILTER_BACKEND': 'rest_framework.filters.DjangoFilterBackend', + 'FILTER_BACKEND': None, 'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser', 'UNAUTHENTICATED_TOKEN': None, @@ -144,8 +144,15 @@ class APISettings(object): if val and attr in self.import_strings: val = perform_import(val, attr) + self.validate_setting(attr, val) + # Cache the result setattr(self, attr, val) return val + def validate_setting(self, attr, val): + if attr == 'FILTER_BACKEND' and val is not None: + # Make sure we can initilize the class + val() + api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 6cdea32fe..af2e6c2e7 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -3,7 +3,7 @@ from decimal import Decimal from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest -from rest_framework import generics, status +from rest_framework import generics, status, filters from rest_framework.compat import django_filters from rest_framework.tests.models import FilterableItem, BasicModel @@ -15,6 +15,7 @@ if django_filters: class FilterFieldsRootView(generics.ListCreateAPIView): model = FilterableItem filter_fields = ['decimal', 'date'] + filter_backend = filters.DjangoFilterBackend # These class are used to test a filter class. class SeveralFieldsFilter(django_filters.FilterSet): @@ -29,6 +30,7 @@ if django_filters: class FilterClassRootView(generics.ListCreateAPIView): model = FilterableItem filter_class = SeveralFieldsFilter + filter_backend = filters.DjangoFilterBackend # These classes are used to test a misconfigured filter class. class MisconfiguredFilter(django_filters.FilterSet): @@ -41,6 +43,7 @@ if django_filters: class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): model = FilterableItem filter_class = MisconfiguredFilter + filter_backend = filters.DjangoFilterBackend class IntegrationTestFiltering(TestCase): diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 7f8cd5247..713a7255b 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -4,7 +4,7 @@ from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest -from rest_framework import generics, status, pagination +from rest_framework import generics, status, pagination, filters from rest_framework.compat import django_filters from rest_framework.tests.models import BasicModel, FilterableItem @@ -31,6 +31,7 @@ if django_filters: model = FilterableItem paginate_by = 10 filter_class = DecimalFilter + filter_backend = filters.DjangoFilterBackend class IntegrationTestPagination(TestCase): From 33a69864625c1953f6f7a94956e1ed07c84e7a44 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 8 Nov 2012 21:47:54 +0000 Subject: [PATCH 21/23] Ensure pagination URLs are fully qualified --- rest_framework/pagination.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 5df3940af..d241ade7c 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -15,7 +15,7 @@ class NextPageField(serializers.Field): return None page = value.next_page_number() request = self.context.get('request') - url = request and request.get_full_path() or '' + url = request and request.build_absolute_uri() or '' return replace_query_param(url, self.page_field, page) @@ -30,7 +30,7 @@ class PreviousPageField(serializers.Field): return None page = value.previous_page_number() request = self.context.get('request') - url = request and request.get_full_path() or '' + url = request and request.build_absolute_uri() or '' return replace_query_param(url, self.page_field, page) From ad9c5d2ffa52a62a7c5cae444f5f4572b35fff2c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 8 Nov 2012 21:49:38 +0000 Subject: [PATCH 22/23] Added @benkonrath, for his excellent work on filtering support. Thank you! --- docs/topics/credits.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index d2549997f..1a2c0db83 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -57,6 +57,7 @@ The following people have helped make REST framework great. * Osiloke Harold Emoekpere - [osiloke] * Michael Shepanski - [mjs7231] * Toni Michel - [tonimichel] +* Ben Konrath - [benkonrath] Many thanks to everyone who's contributed to the project. @@ -148,4 +149,5 @@ To contact the author directly: [jmagnusson]: https://github.com/jmagnusson [osiloke]: https://github.com/osiloke [mjs7231]: https://github.com/mjs7231 -[tonimichel]: https://github.com/tonimichel \ No newline at end of file +[tonimichel]: https://github.com/tonimichel +[benkonrath]: https://github.com/benkonrath From ff1234b711b8dfb7dc1cc539fa9d2b6fd2477825 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 9 Nov 2012 13:05:36 +0000 Subject: [PATCH 23/23] Updated filteing docs. --- docs/api-guide/filtering.md | 75 +++++++++++++++++++++++++++++++------ docs/index.md | 2 + rest_framework/filters.py | 4 +- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index ca901b039..e49ea4207 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -30,7 +30,7 @@ For example: for the currently authenticated user. """ user = self.request.user - return Purchase.objects.filter(purchaser=user) + return Purchase.objects.filter(purchaser=user) ## Filtering against the URL @@ -96,27 +96,76 @@ You must also set the filter backend to `DjangoFilterBackend` in your settings: **Note**: The currently supported version of `django-filter` is the `master` branch. A PyPI release is expected to be coming soon. -## Specifying a FilterSet - -**TODO**: Document setting `.filter_class` on the view. - -**TODO**: Note support for `lookup_type`, double underscore relationship spanning, and ordering. - ## Specifying filter fields -**TODO**: Document setting `.filter_fields` on the view. +If all you need is simple equality-based filtering, you can set a `filter_fields` attribute on the view, listing the set of fields you wish to filter against. -**TODO**: Note that overiding `get_queryset()` can be used together with generic filtering + class ProductList(generics.ListAPIView): + model = Product + serializer_class = ProductSerializer + filter_fields = ('category', 'in_stock') +This will automatically create a `FilterSet` class for the given fields, and will allow you to make requests such as: + + http://example.com/api/products?category=clothing&in_stock=True + +## Specifying a FilterSet + +For more advanced filtering requirements you can specify a `FilterSet` class that should be used by the view. For example: + + class ProductFilter(django_filters.FilterSet): + min_price = django_filters.NumberFilter(lookup_type='gte') + max_price = django_filters.NumberFilter(lookup_type='lte') + class Meta: + model = Product + fields = ['category', 'in_stock', 'min_price', 'max_price'] + + class ProductList(generics.ListAPIView): + model = Product + serializer_class = ProductSerializer + filter_class = ProductFilter + +Which will allow you to make requests such as: + + http://example.com/api/products?category=clothing&max_price=10.00 + +For more details on using filter sets see the [django-filter documentation][django-filter-docs]. + +--- + +**Hints & Tips** + +* By default filtering is not enabled. If you want to use `DjangoFilterBackend` remember to make sure it is installed by using the `'FILTER_BACKEND'` setting. +* When using boolean fields, you should use the values `True` and `False` in the URL query parameters, rather than `0`, `1`, `true` or `false`. (The allowed boolean values are currently hardwired in Django's [NullBooleanSelect implementation][nullbooleanselect].) +* `django-filter` supports filtering across relationships, using Django's double-underscore syntax. + +--- + +## Overriding the intial queryset + +Note that you can use both an overridden `.get_queryset()` and generic filtering together, and everything will work as expected. For example, if `Product` had a many-to-many relationship with `User`, named `purchase`, you might want to write a view like this: + + class PurchasedProductsList(generics.ListAPIView): + """ + Return a list of all the products that the authenticated + user has ever purchased, with optional filtering. + """ + model = Product + serializer_class = ProductSerializer + filter_class = ProductFilter + + def get_queryset(self): + user = self.request.user + return user.purchase_set.all() --- # Custom generic filtering You can also provide your own generic filtering backend, or write an installable app for other developers to use. -To do so overide `BaseFilterBackend`, and override the `.filter_queryset(self, request, queryset, view)` method. +To do so override `BaseFilterBackend`, and override the `.filter_queryset(self, request, queryset, view)` method. -To install the filter, set the `'FILTER_BACKEND'` key in your `'REST_FRAMEWORK'` setting, using the dotted import path of the filter backend class. +To install the filter backend, set the `'FILTER_BACKEND'` key in your `'REST_FRAMEWORK'` setting, using the dotted import path of the filter backend class. For example: @@ -125,4 +174,6 @@ For example: } [cite]: https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-specific-objects-with-filters -[django-filter]: https://github.com/alex/django-filter \ No newline at end of file +[django-filter]: https://github.com/alex/django-filter +[django-filter-docs]: https://django-filter.readthedocs.org/en/latest/index.html +[nullbooleanselect]: https://github.com/django/django/blob/master/django/forms/widgets.py \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 52ea2b75e..1874ec00b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,7 @@ The following packages are optional: * [Markdown][markdown] (2.1.0+) - Markdown support for the self describing API. * [PyYAML][yaml] (3.10+) - YAML content-type support. +* [django-filter][django-filter] (master) - Filtering support. ## Installation @@ -163,6 +164,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [urlobject]: https://github.com/zacharyvoase/urlobject [markdown]: http://pypi.python.org/pypi/Markdown/ [yaml]: http://pypi.python.org/pypi/PyYAML +[django-filter]: https://github.com/alex/django-filter [0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X [image]: img/quickstart.png [sandbox]: http://restframework.herokuapp.com/ diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 14902a69b..ccae48250 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -1,5 +1,7 @@ from rest_framework.compat import django_filters +FilterSet = django_filters and django_filters.FilterSet or None + class BaseFilterBackend(object): """ @@ -17,7 +19,7 @@ class DjangoFilterBackend(BaseFilterBackend): """ A filter backend that uses django-filter. """ - default_filter_set = django_filters.FilterSet + default_filter_set = FilterSet def __init__(self): assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed'