From 3eba34f508a4797c0fe9b152a6a636e477d79ac4 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 1 Jul 2016 16:14:52 -0400 Subject: [PATCH 01/45] strict => Meta.strict --- django_filters/filterset.py | 13 +++++++++---- tests/test_deprecations.py | 22 ++++++++++++++++++++++ tests/test_filtering.py | 2 +- tests/test_filterset.py | 8 +++----- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index e5f31ce..f1188a8 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -153,6 +153,8 @@ class FilterSetOptions(object): self.order_by = getattr(options, 'order_by', False) + self.strict = getattr(options, 'strict', STRICTNESS.RETURN_NO_RESULTS) + self.form = getattr(options, 'form', forms.Form) self.together = getattr(options, 'together', None) @@ -188,6 +190,10 @@ class FilterSetMetaclass(type): raise TypeError("Meta.fields contains a field that isn't defined " "on this FilterSet: {}".format(not_defined)) + if hasattr(new_class, 'strict'): + deprecate('strict has been deprecated. Use Meta.strict instead.') + new_class._meta.strict = new_class.strict + new_class.declared_filters = declared_filters new_class.base_filters = filters return new_class @@ -286,8 +292,6 @@ FILTER_FOR_DBFIELD_DEFAULTS = { class BaseFilterSet(object): filter_overrides = {} order_by_field = ORDER_BY_FIELD - # What to do on on validation errors - strict = STRICTNESS.RETURN_NO_RESULTS def __init__(self, data=None, queryset=None, prefix=None, strict=None): self.is_bound = data is not None @@ -296,8 +300,9 @@ class BaseFilterSet(object): queryset = self._meta.model._default_manager.all() self.queryset = queryset self.form_prefix = prefix - if strict is not None: - self.strict = strict + + # What to do on on validation errors + self.strict = self._meta.strict if strict is None else strict self.filters = copy.deepcopy(self.base_filters) # propagate the model being used through the filters diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 890f5ae..6981788 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -106,3 +106,25 @@ class FilterSetMetaDeprecationTests(TestCase): self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message)) self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) + + +class StrictnessDeprecationTests(TestCase): + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + strict = False + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + strict = False + + self.assertEqual(F._meta.strict, False) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 4fe5e17..d39f3bc 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -857,11 +857,11 @@ class AllValuesFilterTests(TestCase): class F(FilterSet): username = AllValuesFilter() - strict = False class Meta: model = User fields = ['username'] + strict = False self.assertEqual(list(F().qs), list(User.objects.all())) self.assertEqual(list(F({'username': 'alex'}).qs), diff --git a/tests/test_filterset.py b/tests/test_filterset.py index b2f76b6..fdc22a8 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -609,26 +609,24 @@ class FilterSetOrderingTests(TestCase): def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): class F(FilterSet): - strict = STRICTNESS.IGNORE - class Meta: model = User fields = ['username', 'status'] order_by = ['status'] + strict = STRICTNESS.IGNORE - self.assertFalse(F.strict) + self.assertFalse(F._meta.strict) f = F({'o': 'username'}, queryset=self.qs) self.assertQuerysetEqual( f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): class F(FilterSet): - strict = STRICTNESS.RAISE_VALIDATION_ERROR - class Meta: model = User fields = ['username', 'status'] order_by = ['status'] + strict = STRICTNESS.RAISE_VALIDATION_ERROR f = F({'o': 'username'}, queryset=self.qs) with self.assertRaises(ValidationError) as excinfo: From 6413331d16876b4629470862c79fc6482bc1d40d Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 15 Jun 2016 21:16:16 -0400 Subject: [PATCH 02/45] filter_overrides => Meta.filter_overrides --- django_filters/filterset.py | 12 +++++++++--- tests/test_deprecations.py | 39 ++++++++++++++++++++++++++++++++++--- tests/test_filterset.py | 22 +++++++++++---------- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index f1188a8..c5256de 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -151,6 +151,8 @@ class FilterSetOptions(object): self.fields = getattr(options, 'fields', None) self.exclude = getattr(options, 'exclude', None) + self.filter_overrides = getattr(options, 'filter_overrides', {}) + self.order_by = getattr(options, 'order_by', False) self.strict = getattr(options, 'strict', STRICTNESS.RETURN_NO_RESULTS) @@ -177,6 +179,10 @@ class FilterSetMetaclass(type): opts = new_class._meta = FilterSetOptions( getattr(new_class, 'Meta', None)) + if hasattr(new_class, 'filter_overrides'): + deprecate('filter_overrides has been moved to the Meta class.') + new_class._meta.filter_overrides = new_class.filter_overrides + # TODO: replace with deprecations # if opts.model and opts.fields: if opts.model: @@ -290,7 +296,6 @@ FILTER_FOR_DBFIELD_DEFAULTS = { class BaseFilterSet(object): - filter_overrides = {} order_by_field = ORDER_BY_FIELD def __init__(self, data=None, queryset=None, prefix=None, strict=None): @@ -452,7 +457,7 @@ class BaseFilterSet(object): fields = opts.fields if fields is None: DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) - DEFAULTS.update(cls.filter_overrides) + DEFAULTS.update(opts.filter_overrides) fields = get_all_model_fields(model, field_types=DEFAULTS.keys()) return filters_for_model( @@ -499,7 +504,8 @@ class BaseFilterSet(object): @classmethod def filter_for_lookup(cls, f, lookup_type): DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) - DEFAULTS.update(cls.filter_overrides) + if hasattr(cls, '_meta'): + DEFAULTS.update(cls._meta.filter_overrides) data = try_dbfield(DEFAULTS.get, f.__class__) or {} filter_class = data.get('filter_class') diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 6981788..93e94a8 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -95,12 +95,12 @@ class FilterSetMetaDeprecationTests(TestCase): warnings.simplefilter("always") class F(FilterSet): - filter_overrides = { - SubnetMaskField: {'filter_class': CharFilter} - } class Meta: model = NetworkSetting + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter}, + } self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message)) @@ -128,3 +128,36 @@ class StrictnessDeprecationTests(TestCase): strict = False self.assertEqual(F._meta.strict, False) + + +class FilterOverridesDeprecationTests(TestCase): + + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + filter_overrides = {} + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter}, + } + + class Meta: + model = NetworkSetting + fields = '__all__' + + self.assertDictEqual(F._meta.filter_overrides, { + SubnetMaskField: {'filter_class': CharFilter}, + }) + + self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index fdc22a8..1add74e 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -227,14 +227,15 @@ class FilterSetFilterForLookupTests(TestCase): def test_isnull_with_filter_overrides(self): class OFilterSet(FilterSet): - filter_overrides = { - models.BooleanField: { - 'filter_class': BooleanFilter, - 'extra': lambda f: { - 'widget': BooleanWidget, + class Meta: + filter_overrides = { + models.BooleanField: { + 'filter_class': BooleanFilter, + 'extra': lambda f: { + 'widget': BooleanWidget, + }, }, - }, - } + } f = Article._meta.get_field('author') result, params = OFilterSet.filter_for_lookup(f, 'isnull') @@ -483,13 +484,14 @@ class FilterSetClassCreationTests(TestCase): def test_custom_field_gets_filter_from_override(self): class F(FilterSet): - filter_overrides = { - SubnetMaskField: {'filter_class': CharFilter}} - class Meta: model = NetworkSetting fields = '__all__' + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter} + } + self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) def test_filterset_for_proxy_model(self): From d7ce397376a41d869f317070e262a30331c7c769 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 16 Jun 2016 11:58:14 -0400 Subject: [PATCH 03/45] order_by_field => Meta.order_by_field --- django_filters/filterset.py | 15 +++++++++------ tests/test_deprecations.py | 22 ++++++++++++++++++++++ tests/test_filterset.py | 5 ++--- tests/test_forms.py | 13 +++++-------- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index c5256de..15ac379 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -154,6 +154,7 @@ class FilterSetOptions(object): self.filter_overrides = getattr(options, 'filter_overrides', {}) self.order_by = getattr(options, 'order_by', False) + self.order_by_field = getattr(options, 'order_by_field', ORDER_BY_FIELD) self.strict = getattr(options, 'strict', STRICTNESS.RETURN_NO_RESULTS) @@ -200,6 +201,10 @@ class FilterSetMetaclass(type): deprecate('strict has been deprecated. Use Meta.strict instead.') new_class._meta.strict = new_class.strict + if hasattr(new_class, 'order_by_field'): + deprecate('order_by_field has been moved to the Meta class.') + new_class._meta.order_by_field = new_class.order_by_field + new_class.declared_filters = declared_filters new_class.base_filters = filters return new_class @@ -296,8 +301,6 @@ FILTER_FOR_DBFIELD_DEFAULTS = { class BaseFilterSet(object): - order_by_field = ORDER_BY_FIELD - def __init__(self, data=None, queryset=None, prefix=None, strict=None): self.is_bound = data is not None self.data = data or {} @@ -370,8 +373,8 @@ class BaseFilterSet(object): qs = filter_.filter(qs, value) if self._meta.order_by: - order_field = self.form.fields[self.order_by_field] - data = self.form[self.order_by_field].data + order_field = self.form.fields[self._meta.order_by_field] + data = self.form[self._meta.order_by_field].data ordered_value = None try: ordered_value = order_field.clean(data) @@ -381,7 +384,7 @@ class BaseFilterSet(object): # With a None-queryset, ordering must be enforced (#84). if (ordered_value in EMPTY_VALUES and self.strict == STRICTNESS.RETURN_NO_RESULTS): - ordered_value = self.form.fields[self.order_by_field].choices[0][0] + ordered_value = self.form.fields[self._meta.order_by_field].choices[0][0] if ordered_value: qs = qs.order_by(*self.get_order_by(ordered_value)) @@ -396,7 +399,7 @@ class BaseFilterSet(object): fields = OrderedDict([ (name, filter_.field) for name, filter_ in six.iteritems(self.filters)]) - fields[self.order_by_field] = self.ordering_field + fields[self._meta.order_by_field] = self.ordering_field Form = type(str('%sForm' % self.__class__.__name__), (self._meta.form,), fields) if self._meta.together: diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 93e94a8..f49f4d8 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -161,3 +161,25 @@ class FilterOverridesDeprecationTests(TestCase): }) self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) + + +class OrderByFieldDeprecationTests(TestCase): + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + order_by_field = 'field' + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + order_by_field = 'field' + + self.assertEqual(F._meta.order_by_field, 'field') diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 1add74e..519e0d3 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -683,16 +683,15 @@ class FilterSetOrderingTests(TestCase): def test_ordering_with_overridden_field_name(self): """ - Set the `order_by_field` on the queryset and ensure that the + Set the `order_by_field` on the filterset and ensure that the field name is respected. """ class F(FilterSet): - order_by_field = 'order' - class Meta: model = User fields = ['username', 'status'] order_by = ['status'] + order_by_field = 'order' f = F({'order': 'status'}, queryset=self.qs) self.assertQuerysetEqual( diff --git a/tests/test_forms.py b/tests/test_forms.py index 1666291..4d5431e 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -215,16 +215,15 @@ class FilterSetFormTests(TestCase): def test_ordering_with_overridden_field_name(self): """ - Set the `order_by_field` on the queryset and ensure that the + Set the `order_by_field` on the filterset and ensure that the field name is respected. """ class F(FilterSet): - order_by_field = 'order' - class Meta: model = User fields = ['username', 'status'] order_by = ['status'] + order_by_field = 'order' f = F().form self.assertNotIn('o', f.fields) @@ -233,16 +232,15 @@ class FilterSetFormTests(TestCase): def test_ordering_with_overridden_field_name_and_descending(self): """ - Set the `order_by_field` on the queryset and ensure that the + Set the `order_by_field` on the filterset and ensure that the field name is respected. """ class F(FilterSet): - order_by_field = 'order' - class Meta: model = User fields = ['username', 'status'] order_by = ['status', '-status'] + order_by_field = 'order' f = F().form self.assertNotIn('o', f.fields) @@ -251,12 +249,11 @@ class FilterSetFormTests(TestCase): def test_ordering_with_overridden_field_name_and_using_all_fields(self): class F(FilterSet): - order_by_field = 'order' - class Meta: model = User fields = ['username', 'status'] order_by = True + order_by_field = 'order' f = F().form self.assertIn('order', f.fields) From c79dffb0a6423d2d70a4f47e1aec66653aab4527 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 14 Aug 2016 20:18:10 -0400 Subject: [PATCH 04/45] Add filter options migration docs --- docs/migration.txt | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/migration.txt b/docs/migration.txt index 5f7afb7..cb66668 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -52,3 +52,33 @@ the ``Meta.exclude`` attribute. class Meta: model = User exclude = ['password'] + + +Move FilterSet options to Meta class +------------------------------------ +Details: https://github.com/carltongibson/django-filter/issues/430 + +Several ``FilterSet`` options have been moved to the ``Meta`` class to prevent +potential conflicts with declared filter names. This includes: + +* ``filter_overrides`` +* ``strict`` +* ``order_by_field`` + +.. code-block:: python + + # 0.x + class UserFilter(FilterSet): + filter_overrides = {} + strict = STRICTNESS.RAISE_VALIDATION_ERROR + order_by_field = 'order' + ... + + # 1.0 + class UserFilter(FilterSet): + ... + + class Meta: + filter_overrides = {} + strict = STRICTNESS.RAISE_VALIDATION_ERROR + order_by_field = 'order' From 6a5610ad574369c5a90d9f9eb1a0dc81f8af3722 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 28 Feb 2016 17:13:58 -0500 Subject: [PATCH 05/45] Deprecate MethodFilter and Filter.action --- django_filters/filters.py | 74 ++++++++++++++- tests/test_deprecations.py | 185 ++++++++++++++++++++++++++++++++++++- tests/test_filtering.py | 101 +++----------------- tests/test_filters.py | 8 +- tests/test_filterset.py | 103 +++++++++++++++++++++ 5 files changed, 377 insertions(+), 94 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 39e46cf..d517082 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -17,6 +17,7 @@ from .fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField ) +from .utils import deprecate __all__ = [ @@ -49,6 +50,9 @@ __all__ = [ LOOKUP_TYPES = sorted(QUERY_TERMS) +EMPTY_VALUES = ([], (), {}, '', None) + + def _lookup_type_warning(): warnings.warn('lookup_type is deprecated. Use lookup_expr instead.', DeprecationWarning, stacklevel=3) @@ -57,12 +61,14 @@ class Filter(object): creation_counter = 0 field_class = forms.Field - def __init__(self, name=None, label=None, widget=None, action=None, + def __init__(self, name=None, label=None, widget=None, action=None, method=None, lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs): self.name = name self.label = label if action: + deprecate('Filter.action has been deprecated in favor of Filter.method') self.filter = action + self.method = method self.lookup_expr = lookup_expr if 'lookup_type' in kwargs: @@ -84,6 +90,28 @@ class Filter(object): """ return qs.exclude if self.exclude else qs.filter + def method(): + """ + Filter method needs to be lazily resolved, as it may be dependent on + the 'parent' FilterSet. + """ + def fget(self): + return self._method + + def fset(self, value): + self._method = value + + # clear existing FilterMethod + if isinstance(self.filter, FilterMethod): + del self.filter + + # override filter w/ FilterMethod. + if value is not None: + self.filter = FilterMethod(self) + + return locals() + method = property(**method()) + def lookup_type(): def fget(self): _lookup_type_warning() @@ -144,7 +172,7 @@ class Filter(object): value = value.value else: lookup = self.lookup_expr - if value in ([], (), {}, None, ''): + if value in EMPTY_VALUES: return qs if self.distinct: qs = qs.distinct() @@ -458,6 +486,8 @@ class MethodFilter(Filter): This filter will allow you to run a method that exists on the filterset class """ def __init__(self, *args, **kwargs): + deprecate('MethodFilter has been deprecated in favor of Filter.method') + # Get the action out of the kwargs action = kwargs.get('action', None) @@ -492,3 +522,43 @@ class MethodFilter(Filter): if parent_filter_method is not None: return parent_filter_method(qs, value) return qs + + +class FilterMethod(object): + """ + This helper is used to override Filter.filter() when a 'method' argument + is passed. It proxies the call to the actual method on the filter's parent. + """ + def __init__(self, filter_instance): + self.f = filter_instance + + def __call__(self, qs, value): + if value in EMPTY_VALUES: + return qs + + return self.method(qs, self.f.name, value) + + @property + def method(self): + """ + Resolve the method on the parent filterset. + """ + instance = self.f + + # noop if 'method' is a function + if callable(instance.method): + return instance.method + + # otherwise, method is the name of a method on the parent FilterSet. + assert hasattr(instance, 'parent'), \ + "Filter '%s' must have a parent FilterSet to find '.%s()'" % \ + (instance.name, instance.method) + + parent = instance.parent + method = getattr(parent, instance.method, None) + + assert callable(method), \ + "Expected parent FilterSet '%s.%s' to have a '.%s()' method." % \ + (parent.__class__.__module__, parent.__class__.__name__, instance.method) + + return method diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 890f5ae..4f2bf4b 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -1,14 +1,26 @@ +import functools import warnings +import mock from django.test import TestCase from django_filters import FilterSet -from django_filters.filters import CharFilter +from django_filters.filters import Filter, CharFilter, MethodFilter from .models import User from .models import NetworkSetting from .models import SubnetMaskField +def silence(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + f(*args, **kwargs) + + return wrapped + + class UserFilter(FilterSet): class Meta: model = User @@ -53,6 +65,177 @@ class FilterSetContainerDeprecationTests(TestCase): self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) +class MethodFilterDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + # old tests + @silence + def test_filtering(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter(action='filter_username') + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username=value + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_external(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + def filter_username(queryset, value): + return queryset.filter( + username=value + ) + + class F(FilterSet): + username = MethodFilter(action=filter_username) + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_default_attribute_action(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username__contains='ke' + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')],) + self.assertEqual(list(F({'username': 'jake'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + self.assertEqual(list(F({'username': 'aaron'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + + @silence + def test_filtering_default(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + email = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'jake'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'aaron'}).qs), + list(User.objects.all())) + + +class FilterActionDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda x: x) + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filter_using_action(self): + qs = mock.NonCallableMock(spec=[]) + action = mock.Mock(spec=['filter']) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + f = Filter(action=action) + result = f.filter(qs, 'value') + action.assert_called_once_with(qs, 'value') + self.assertNotEqual(qs, result) + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filtering_with_action(self): + User.objects.create(username='alex', status=1) + User.objects.create(username='jacob', status=2) + User.objects.create(username='aaron', status=2) + User.objects.create(username='carl', status=0) + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda qs, value: ( + qs.filter(**{'username__startswith': value}) + )) + + class Meta: + model = User + fields = ['username'] + + f = F({'username': 'a'}, queryset=User.objects.all()) + self.assertQuerysetEqual( + f.qs, ['alex', 'aaron'], lambda o: o.username, False) + + class FilterSetMetaDeprecationTests(TestCase): def test_fields_not_set(self): with warnings.catch_warnings(record=True) as w: diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 4fe5e17..85ca673 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -21,7 +21,6 @@ from django_filters.filters import DateFromToRangeFilter from django_filters.filters import DateTimeFromToRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import DurationFilter -from django_filters.filters import MethodFilter from django_filters.filters import MultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import NumberFilter @@ -153,7 +152,6 @@ class ChoiceFilterTests(TestCase): f = F({'status': '0'}) self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - def test_filtering_on_explicitly_defined_field(self): """ Test for #30. @@ -167,6 +165,7 @@ class ChoiceFilterTests(TestCase): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES) + class Meta: model = User fields = ['status'] @@ -186,7 +185,6 @@ class ChoiceFilterTests(TestCase): self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - class MultipleChoiceFilterTests(TestCase): def test_filtering(self): @@ -893,24 +891,23 @@ class AllValuesMultipleFilterTests(TestCase): list()) -class MethodFilterTests(TestCase): +class FilterMethodTests(TestCase): + + def setUp(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') def test_filtering(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - class F(FilterSet): - username = MethodFilter(action='filter_username') + username = CharFilter(method='filter_username') class Meta: model = User fields = ['username'] - def filter_username(self, queryset, value): - return queryset.filter( - username=value - ) + def filter_username(self, queryset, name, value): + return queryset.filter(**{name: value}) self.assertEqual(list(F().qs), list(User.objects.all())) self.assertEqual(list(F({'username': 'alex'}).qs), @@ -918,18 +915,12 @@ class MethodFilterTests(TestCase): self.assertEqual(list(F({'username': 'jose'}).qs), list()) - def test_filtering_external(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - - def filter_username(queryset, value): - return queryset.filter( - username=value - ) + def test_filtering_callable(self): + def filter_username(queryset, name, value): + return queryset.filter(**{name: value}) class F(FilterSet): - username = MethodFilter(action=filter_username) + username = CharFilter(method=filter_username) class Meta: model = User @@ -942,57 +933,6 @@ class MethodFilterTests(TestCase): list()) - def test_filtering_default_attribute_action(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')],) - self.assertEqual(list(F({'username': 'jake'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - self.assertEqual(list(F({'username': 'aaron'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - - - def test_filtering_default(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - email = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'jake'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'aaron'}).qs), - list(User.objects.all())) - - class O2ORelationshipTests(TestCase): def setUp(self): @@ -1682,19 +1622,6 @@ class MiscFilterSetTests(TestCase): f = F({'username': 'alex', 'status': '2'}, queryset=qs) self.assertQuerysetEqual(f.qs, [], lambda o: o.pk) - def test_filter_with_action(self): - class F(FilterSet): - username = CharFilter(action=lambda qs, value: ( - qs.filter(**{'username__startswith': value}))) - - class Meta: - model = User - fields = ['username'] - - f = F({'username': 'a'}, queryset=User.objects.all()) - self.assertQuerysetEqual( - f.qs, ['alex', 'aaron'], lambda o: o.username, False) - def test_filter_with_initial(self): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES, initial=1) diff --git a/tests/test_filters.py b/tests/test_filters.py index 8aa0ab0..4d0ceec 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -193,12 +193,12 @@ class FilterTests(TestCase): qs.filter.assert_called_once_with(somefield__exact='value') self.assertNotEqual(qs, result) - def test_filter_using_action(self): + def test_filter_using_method(self): qs = mock.NonCallableMock(spec=[]) - action = mock.Mock(spec=['filter']) - f = Filter(action=action) + method = mock.Mock() + f = Filter(method=method) result = f.filter(qs, 'value') - action.assert_called_once_with(qs, 'value') + method.assert_called_once_with(qs, None, 'value') self.assertNotEqual(qs, result) def test_filtering_uses_distinct(self): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index b2f76b6..bf8c079 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -11,6 +11,7 @@ from django.test import TestCase from django_filters.filterset import FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS from django_filters.filterset import STRICTNESS +from django_filters.filters import Filter from django_filters.filters import BooleanFilter from django_filters.filters import CharFilter from django_filters.filters import NumberFilter @@ -20,6 +21,8 @@ from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import UUIDFilter from django_filters.filters import BaseInFilter from django_filters.filters import BaseRangeFilter +from django_filters.filters import DateRangeFilter +from django_filters.filters import FilterMethod from django_filters.widgets import BooleanWidget @@ -785,6 +788,106 @@ class FilterSetTogetherTests(TestCase): self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk) +# test filter.method here, as it depends on its parent FilterSet +class FilterMethodTests(TestCase): + + def test_none(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method=None) + self.assertIsNone(f.method) + + # passing method=None should not modify filter function + self.assertIs(f.filter, TestFilter.filter) + + def test_method_name(self): + class F(FilterSet): + f = Filter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_callable(self): + def filter_f(qs, name, value): + pass + + class F(FilterSet): + f = Filter(method=filter_f) + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, filter_f) + self.assertEqual(f.filters['f'].filter.method, filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_with_overridden_filter(self): + # Some filter classes override the base filter() method. We need + # to ensure that passing a method argument still works correctly + class F(FilterSet): + f = DateRangeFilter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + + def test_parent_unresolvable(self): + f = Filter(method='filter_f') + with self.assertRaises(AssertionError) as w: + f.filter(User.objects.all(), 0) + + self.assertIn("'None'", str(w.exception)) + self.assertIn('parent', str(w.exception)) + self.assertIn('filter_f', str(w.exception)) + + def test_method_unresolvable(self): + class F(FilterSet): + f = Filter(method='filter_f') + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_uncallable(self): + class F(FilterSet): + f = Filter(method='filter_f') + filter_f = 4 + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_set_unset(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method='filter_f') + self.assertEqual(f.method, 'filter_f') + self.assertIsInstance(f.filter, FilterMethod) + + # setting None should revert to Filter.filter + f.method = None + self.assertIsNone(f.method) + self.assertIs(f.filter, TestFilter.filter) + + @unittest.skip('TODO: remove when relevant deprecations have been completed') class MiscFilterSetTests(TestCase): From 1d350f144f464dd7e4107b918da55325c62c94b3 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Aug 2016 14:39:54 -0400 Subject: [PATCH 06/45] Add ref & migration docs, update guide --- docs/migration.txt | 36 ++++++++++++++++++++++ docs/ref/filters.txt | 48 ++++++++++++++++++++++++++--- docs/usage.txt | 72 ++++++++------------------------------------ 3 files changed, 92 insertions(+), 64 deletions(-) diff --git a/docs/migration.txt b/docs/migration.txt index 5f7afb7..f4467f6 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -8,6 +8,42 @@ release has also been created to help with migration. It is compatible with both the existing and new APIs and will raise warnings for deprecated behavior. +MethodFilter and Filter.action replaced by Filter.method +-------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/382 + +The functionality of ``MethodFilter`` and ``Filter.action`` has been merged +together and replaced by the ``Filter.method`` paramater. The ``method`` +parameter takes either a callable or the name of a ``FilterSet`` method. The +signature now takes an additional ``name`` argument that is the name of the +model field to be filtered on. + +Since ``method`` is now a parameter of all filters, inputs are validated and +cleaned by its ``field_class``. The function will receive the cleaned value +instead of the raw value. + +.. code-block:: python + + # 0.x + class UserFilter(FilterSet): + last_login = filters.MethodFilter() + + def filter_last_login(self, qs, value): + # try to convert value to datetime, which may fail. + if value and looks_like_a_date(value): + value = datetime(value) + + return qs.filter(last_login=value}) + + + # 1.0 + class UserFilter(FilterSet): + last_login = filters.CharFilter(method='filter_last_login') + + def filter_last_login(self, qs, name, value): + return qs.filter(**{name: value}) + + QuerySet methods are no longer proxied -------------------------------------- Details: https://github.com/carltongibson/django-filter/pull/440 diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index aabe031..cf8ea76 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -43,13 +43,51 @@ additional ones that django-filter provides which may be useful: * :ref:`RangeWidget ` -- this widget is used with ``RangeFilter`` to generate two form input elements using a single field. -``action`` +.. _filter-method: + +``method`` ~~~~~~~~~~ -An optional callable that tells the filter how to handle the queryset. It -recieves a ``QuerySet`` and the value to filter on and should return a -``Queryset`` that is filtered appropriately. `action` will default to -``filter_{value-of-name-attribute}`` +An optional argument that tells the filter how to handle the queryset. It can +accept either a callable or the name of a method on the ``FilterSet``. The +method receives a ``QuerySet``, the name of the model field to filter on, and +the value to filter with. It should return a ``Queryset`` that is filtered +appropriately. + +The passed in value is validated and cleaned by the filter's ``field_class``, +so raw value transformation and empty value checking should be unnecessary. + +.. code-block:: python + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method='filter_published') + + def filter_published(self, queryset, name, value): + # construct the full lookup expression. + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + # alternatively, it may not be necessary to construct the lookup. + return queryset.filter(published_on__isnull=False) + + class Meta: + model = Book + fields = ['published'] + + + # Callables may also be defined out of the class scope. + def filter_not_empty(queryset, name, value): + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method=filter_not_empty) + + class Meta: + model = Book + fields = ['published'] ``lookup_expr`` ~~~~~~~~~~~~~~~ diff --git a/docs/usage.txt b/docs/usage.txt index 1abfe9c..7e13497 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -236,71 +236,25 @@ default filters for all the models fields of the same kind using } -Custom filtering with MethodFilter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Customize filtering with ``Filter.method`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you want fine control over each individual filter attribute, you can use -the ``MethodFilter`` filter. +You can control the behavior of a filter by specifying a ``method`` to perform +filtering. View more information in the :ref:`method reference `. -By passing in the name of a custom defined filter function as an ``action``, -the filter attribute gets linked to the custom filter function. -Here is an example of overriding the filter function of the -filter attribute ``username`` -:: +.. code-block:: python class F(django_filters.FilterSet): - username = MethodFilter(action='my_custom_filter') + username = MethodFilter(action='my_custom_filter') - class Meta: - model = User - fields = ['username'] + class Meta: + model = User + fields = ['username'] - def my_custom_filter(self, queryset, value): - return queryset.filter( - username=value - ) - - -The filter function can also be defined outside of the filter class scope. -Though you would need to pass in the actual function value, not it's name. -:: - - def my_custom_filter(queryset, value): - return queryset.filter( - username=value - ) - - class F(django_filters.FilterSet): - # Notice: In this case, action accepts a func, not a string - username = MethodFilter(action=my_custom_filter) - - class Meta: - model = User - fields = ['username'] - - -Lastly, when using a ``MethodFilter``, there is no need to define an action. -You may simply do the following and ``filter_username`` will be auto-detected -and used. :: - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - -Under the hood, if ``action`` is not defined, ``django_filter`` -searches for a class method with a name that follows the pattern -``filter_{{ATTRIBUTE_NAME}}``. For example, if the attribute name is -``email``, then the filter class will be scanned for the filter function -``filter_email``. If no action is provided, and no filter class -function is found, then the filter attribute will be left unfiltered. + def my_custom_filter(self, queryset, name, value): + return queryset.filter(**{ + name: value, + }) The view From 78eef27a882d18ba2cea7dc5a714c2796f32f8e8 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 14 Aug 2016 21:49:10 -0400 Subject: [PATCH 07/45] whoops --- docs/usage.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.txt b/docs/usage.txt index 7e13497..3b5d8a3 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -245,7 +245,7 @@ filtering. View more information in the :ref:`method reference `. .. code-block:: python class F(django_filters.FilterSet): - username = MethodFilter(action='my_custom_filter') + username = CharFilter(method='my_custom_filter') class Meta: model = User From d1e8737e029aa2010979fcef7b353602866edbf6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 19 Aug 2016 21:50:36 +0200 Subject: [PATCH 08/45] Some doc fixes, mostly for MultipleChoiceFilter --- django_filters/filters.py | 23 ++++++++++++----------- docs/ref/filters.txt | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 39e46cf..6f2a935 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -174,21 +174,21 @@ class UUIDFilter(Filter): class MultipleChoiceFilter(Filter): """ - This filter preforms OR(by default) or AND(using conjoined=True) query + This filter performs OR(by default) or AND(using conjoined=True) query on the selected options. - Advanced Use - ------------ + Advanced usage + -------------- Depending on your application logic, when all or no choices are selected, - filtering may be a noop. In this case you may wish to avoid the filtering - overhead, particularly if using a `distinct` call. + filtering may be a no-operation. In this case you may wish to avoid the + filtering overhead, particularly if using a `distinct` call. - Set `always_filter` to False after instantiation to enable the default - `is_noop` test. + Set `always_filter` to `False` after instantiation to enable the default + `is_noop` test. You can override `is_noop` if you need a different test + for your application. - Override `is_noop` if you require a different test for your application. - - `distinct` defaults to True on this class to preserve backward compatibility. + `distinct` defaults to `True` on this class to preserve backward + compatibility. """ field_class = forms.MultipleChoiceField @@ -205,7 +205,8 @@ class MultipleChoiceFilter(Filter): def is_noop(self, qs, value): """ - Return True to short-circuit unnecessary and potentially slow filtering. + Return `True` to short-circuit unnecessary and potentially slow + filtering. """ if self.always_filter: return False diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index aabe031..4546bc6 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -114,7 +114,7 @@ Any additional keyword arguments are stored as the ``extra`` parameter on the fi ModelChoiceFilter and ModelMultipleChoiceFilter arguments --------------------------------------------------------- -These arguments apply specifically to ModelChoiceFiler and +These arguments apply specifically to ModelChoiceFilter and ModelMultipleChoiceFilter only. ``queryset`` From dc8266dad1c3e14473c3f0428a466c1268950e10 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 19 Aug 2016 21:55:49 +0200 Subject: [PATCH 09/45] Simplify MultipleChoiceFilter.__init__ --- django_filters/filters.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 39e46cf..c9c9fbf 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -195,12 +195,8 @@ class MultipleChoiceFilter(Filter): always_filter = True def __init__(self, *args, **kwargs): - distinct = kwargs.get('distinct', True) - kwargs['distinct'] = distinct - - conjoined = kwargs.pop('conjoined', False) - self.conjoined = conjoined - + kwargs['distinct'] = kwargs.get('distinct', True) + self.conjoined = kwargs.pop('conjoined', False) super(MultipleChoiceFilter, self).__init__(*args, **kwargs) def is_noop(self, qs, value): From f4e51fb013654302601dd0cf3198e707cb7386d6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 19 Aug 2016 22:22:27 +0200 Subject: [PATCH 10/45] flake8 fixes Ignoring E501 (line too long), and docs/. --- django_filters/filterset.py | 2 +- django_filters/widgets.py | 6 +++--- setup.py | 2 +- tests/test_filtering.py | 5 +---- tests/test_filters.py | 27 +++++++++++++++------------ tests/test_filterset.py | 11 +++++++---- tests/test_forms.py | 28 ++++++++++++++++++++-------- tests/test_widgets.py | 14 ++++++-------- 8 files changed, 54 insertions(+), 41 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index e5f31ce..76df4d1 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -15,7 +15,7 @@ from django.utils import six from django.utils.text import capfirst from django.utils.translation import ugettext as _ -from .compat import remote_field, remote_model, remote_queryset +from .compat import remote_field, remote_queryset from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, diff --git a/django_filters/widgets.py b/django_filters/widgets.py index fc14c04..076dc66 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -68,9 +68,9 @@ class LinkWidget(forms.Widget): except AttributeError: url = urlencode(data) return self.option_string() % { - 'attrs': selected and ' class="selected"' or '', - 'query_string': url, - 'label': force_text(option_label) + 'attrs': selected and ' class="selected"' or '', + 'query_string': url, + 'label': force_text(option_label) } def option_string(self): diff --git a/setup.py b/setup.py index 3f9264a..b32ed5c 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ setup( maintainer_email='carlton.gibson@noumenal.es', url='http://github.com/carltongibson/django-filter/tree/master', packages=find_packages(exclude=['tests']), - package_data = { + package_data={ 'django_filters': [ 'locale/*/LC_MESSAGES/*', ], diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 4fe5e17..691b277 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -153,7 +153,6 @@ class ChoiceFilterTests(TestCase): f = F({'status': '0'}) self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - def test_filtering_on_explicitly_defined_field(self): """ Test for #30. @@ -167,6 +166,7 @@ class ChoiceFilterTests(TestCase): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES) + class Meta: model = User fields = ['status'] @@ -186,7 +186,6 @@ class ChoiceFilterTests(TestCase): self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - class MultipleChoiceFilterTests(TestCase): def test_filtering(self): @@ -941,7 +940,6 @@ class MethodFilterTests(TestCase): self.assertEqual(list(F({'username': 'jose'}).qs), list()) - def test_filtering_default_attribute_action(self): User.objects.create(username='mike') User.objects.create(username='jake') @@ -970,7 +968,6 @@ class MethodFilterTests(TestCase): [User.objects.get(username='mike'), User.objects.get(username='jake')]) - def test_filtering_default(self): User.objects.create(username='mike') User.objects.create(username='jake') diff --git a/tests/test_filters.py b/tests/test_filters.py index 8aa0ab0..8a2f880 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from datetime import date, time, timedelta, datetime import mock import warnings -import unittest from django import forms from django.test import TestCase, override_settings @@ -112,29 +111,32 @@ class FilterTests(TestCase): def test_field_params(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(name='somefield', label='somelabel', - widget='somewidget') + widget='somewidget') f.field mocked.assert_called_once_with(required=False, - label='somelabel', widget='somewidget', help_text=mock.ANY) + label='somelabel', + widget='somewidget', + help_text=mock.ANY) def test_field_extra_params(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(someattr='someattr') f.field mocked.assert_called_once_with(required=mock.ANY, - label=mock.ANY, widget=mock.ANY, help_text=mock.ANY, - someattr='someattr') + label=mock.ANY, widget=mock.ANY, + help_text=mock.ANY, + someattr='someattr') def test_field_with_required_filter(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(required=True) f.field - mocked.assert_called_once_with(required=True, - label=mock.ANY, widget=mock.ANY, help_text=mock.ANY) + mocked.assert_called_once_with(required=True, label=mock.ANY, + widget=mock.ANY, help_text=mock.ANY) def test_filtering(self): qs = mock.Mock(spec=['filter']) @@ -436,7 +438,7 @@ class MultipleChoiceFilterTests(TestCase): [5, ]), ((books[3].pk, books[4].pk), []), - ) + ) users = User.objects.all() for item in filter_list: @@ -858,7 +860,8 @@ class AllValuesFilterTests(TestCase): def test_default_field_with_assigning_model(self): mocked = mock.Mock() chained_call = '.'.join(['_default_manager', 'distinct.return_value', - 'order_by.return_value', 'values_list.return_value']) + 'order_by.return_value', + 'values_list.return_value']) mocked.configure_mock(**{chained_call: iter([])}) f = AllValuesFilter() f.model = mocked diff --git a/tests/test_filterset.py b/tests/test_filterset.py index b2f76b6..9c615c8 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -37,7 +37,6 @@ from .models import BankAccount from .models import Node from .models import DirectedNode from .models import Worker -from .models import HiredWorker from .models import Business from .models import UUIDTestModel @@ -392,9 +391,10 @@ class FilterSetClassCreationTests(TestCase): class Meta: model = Book - fields = ('username', 'price', 'other') + fields = ('username', 'price', 'other', 'another') self.assertEqual(excinfo.exception.args, ( - "Meta.fields contains a field that isn't defined on this FilterSet: other",)) + "Meta.fields contains a field that isn't defined " + "on this FilterSet: other",)) def test_meta_fields_dictionary_containing_unknown(self): with self.assertRaises(TypeError): @@ -405,7 +405,7 @@ class FilterSetClassCreationTests(TestCase): fields = {'id': ['exact'], 'title': ['exact'], 'other': ['exact'], - } + } def test_meta_exlude_with_declared_and_declared_wins(self): class F(FilterSet): @@ -660,6 +660,7 @@ class FilterSetOrderingTests(TestCase): def test_ordering_uses_filter_name(self): class F(FilterSet): account = CharFilter(name='username') + class Meta: model = User fields = ['account', 'status'] @@ -672,6 +673,7 @@ class FilterSetOrderingTests(TestCase): def test_reverted_ordering_uses_filter_name(self): class F(FilterSet): account = CharFilter(name='username') + class Meta: model = User fields = ['account', 'status'] @@ -725,6 +727,7 @@ class FilterSetOrderingTests(TestCase): class F(FilterSet): debug = True + class Meta: model = User fields = ['username', 'status'] diff --git a/tests/test_forms.py b/tests/test_forms.py index 1666291..0fabd67 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -184,8 +184,11 @@ class FilterSetFormTests(TestCase): order_by = True f = F().form - self.assertEqual(f.fields['o'].choices, - [('username', 'Username'), ('-username', 'Username (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) def test_ordering_uses_filter_label(self): class F(FilterSet): @@ -197,8 +200,11 @@ class FilterSetFormTests(TestCase): order_by = True f = F().form - self.assertEqual(f.fields['o'].choices, - [('username', 'Account'), ('-username', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Account'), + ('-username', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) def test_ordering_uses_explicit_filter_name(self): class F(FilterSet): @@ -210,8 +216,11 @@ class FilterSetFormTests(TestCase): order_by = True f = F().form - self.assertEqual(f.fields['o'].choices, - [('account', 'Account'), ('-account', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) + self.assertEqual(f.fields['o'].choices, [ + ('account', 'Account'), + ('-account', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) def test_ordering_with_overridden_field_name(self): """ @@ -260,8 +269,11 @@ class FilterSetFormTests(TestCase): f = F().form self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, - [('username', 'Username'), ('-username', 'Username (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) + self.assertEqual(f.fields['order'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) def test_ordering_with_custom_display_names(self): class F(FilterSet): diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 57caea3..2cfb93c 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -78,15 +78,13 @@ class LinkWidgetTests(TestCase): def test_widget_with_option_groups(self): choices = ( ('Audio', ( - ('vinyl', 'Vinyl'), - ('cd', 'CD'), - ) - ), + ('vinyl', 'Vinyl'), + ('cd', 'CD'), + )), ('Video', ( - ('vhs', 'VHS Tape'), - ('dvd', 'DVD'), - ) - ), + ('vhs', 'VHS Tape'), + ('dvd', 'DVD'), + )), ('unknown', 'Unknown'), ) From b28fdc1de7c6760a71a56af8c6fe34c74c627ba3 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 19 Aug 2016 23:02:58 +0200 Subject: [PATCH 11/45] Cleanup MultipleChoiceFilter.filter --- django_filters/filters.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 39e46cf..2312477 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -217,26 +217,26 @@ class MultipleChoiceFilter(Filter): return False def filter(self, qs, value): - value = value or () # Make sure we have an iterable + if not value: + # Even though not a noop, no point filtering if empty. + return qs if self.is_noop(qs, value): return qs - # Even though not a noop, no point filtering if empty - if not value: - return qs - - q = Q() + if not self.conjoined: + q = Q() for v in set(value): + predicate = {self.name: v} if self.conjoined: - qs = self.get_method(qs)(**{self.name: v}) + qs = self.get_method(qs)(**predicate) else: - q |= Q(**{self.name: v}) + q |= Q(**predicate) - if self.distinct: - return self.get_method(qs)(q).distinct() + if not self.conjoined: + qs = self.get_method(qs)(q) - return self.get_method(qs)(q) + return qs.distinct() if self.distinct else qs class DateFilter(Filter): From ed6a22001ae2e423d094850d6dbe97bb0a8b69fe Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 22 Aug 2016 20:39:49 +0200 Subject: [PATCH 12/45] Use kwargs.setdefault in MultipleChoiceFilter Ref: https://github.com/carltongibson/django-filter/pull/463#discussion_r75585072 --- django_filters/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 4d55326..97d8f78 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -195,7 +195,7 @@ class MultipleChoiceFilter(Filter): always_filter = True def __init__(self, *args, **kwargs): - kwargs['distinct'] = kwargs.get('distinct', True) + kwargs.setdefault('distinct', True) self.conjoined = kwargs.pop('conjoined', False) super(MultipleChoiceFilter, self).__init__(*args, **kwargs) From 6b65e9692f5e86821291df84771680f0114601af Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 28 Feb 2016 17:13:58 -0500 Subject: [PATCH 13/45] Deprecate MethodFilter and Filter.action --- django_filters/filters.py | 74 ++++++++++++++- tests/test_deprecations.py | 185 ++++++++++++++++++++++++++++++++++++- tests/test_filtering.py | 96 +++---------------- tests/test_filters.py | 8 +- tests/test_filterset.py | 103 +++++++++++++++++++++ 5 files changed, 376 insertions(+), 90 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 97d8f78..c3d46c7 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -17,6 +17,7 @@ from .fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField ) +from .utils import deprecate __all__ = [ @@ -49,6 +50,9 @@ __all__ = [ LOOKUP_TYPES = sorted(QUERY_TERMS) +EMPTY_VALUES = ([], (), {}, '', None) + + def _lookup_type_warning(): warnings.warn('lookup_type is deprecated. Use lookup_expr instead.', DeprecationWarning, stacklevel=3) @@ -57,12 +61,14 @@ class Filter(object): creation_counter = 0 field_class = forms.Field - def __init__(self, name=None, label=None, widget=None, action=None, + def __init__(self, name=None, label=None, widget=None, action=None, method=None, lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs): self.name = name self.label = label if action: + deprecate('Filter.action has been deprecated in favor of Filter.method') self.filter = action + self.method = method self.lookup_expr = lookup_expr if 'lookup_type' in kwargs: @@ -84,6 +90,28 @@ class Filter(object): """ return qs.exclude if self.exclude else qs.filter + def method(): + """ + Filter method needs to be lazily resolved, as it may be dependent on + the 'parent' FilterSet. + """ + def fget(self): + return self._method + + def fset(self, value): + self._method = value + + # clear existing FilterMethod + if isinstance(self.filter, FilterMethod): + del self.filter + + # override filter w/ FilterMethod. + if value is not None: + self.filter = FilterMethod(self) + + return locals() + method = property(**method()) + def lookup_type(): def fget(self): _lookup_type_warning() @@ -144,7 +172,7 @@ class Filter(object): value = value.value else: lookup = self.lookup_expr - if value in ([], (), {}, None, ''): + if value in EMPTY_VALUES: return qs if self.distinct: qs = qs.distinct() @@ -455,6 +483,8 @@ class MethodFilter(Filter): This filter will allow you to run a method that exists on the filterset class """ def __init__(self, *args, **kwargs): + deprecate('MethodFilter has been deprecated in favor of Filter.method') + # Get the action out of the kwargs action = kwargs.get('action', None) @@ -489,3 +519,43 @@ class MethodFilter(Filter): if parent_filter_method is not None: return parent_filter_method(qs, value) return qs + + +class FilterMethod(object): + """ + This helper is used to override Filter.filter() when a 'method' argument + is passed. It proxies the call to the actual method on the filter's parent. + """ + def __init__(self, filter_instance): + self.f = filter_instance + + def __call__(self, qs, value): + if value in EMPTY_VALUES: + return qs + + return self.method(qs, self.f.name, value) + + @property + def method(self): + """ + Resolve the method on the parent filterset. + """ + instance = self.f + + # noop if 'method' is a function + if callable(instance.method): + return instance.method + + # otherwise, method is the name of a method on the parent FilterSet. + assert hasattr(instance, 'parent'), \ + "Filter '%s' must have a parent FilterSet to find '.%s()'" % \ + (instance.name, instance.method) + + parent = instance.parent + method = getattr(parent, instance.method, None) + + assert callable(method), \ + "Expected parent FilterSet '%s.%s' to have a '.%s()' method." % \ + (parent.__class__.__module__, parent.__class__.__name__, instance.method) + + return method diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 890f5ae..4f2bf4b 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -1,14 +1,26 @@ +import functools import warnings +import mock from django.test import TestCase from django_filters import FilterSet -from django_filters.filters import CharFilter +from django_filters.filters import Filter, CharFilter, MethodFilter from .models import User from .models import NetworkSetting from .models import SubnetMaskField +def silence(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + f(*args, **kwargs) + + return wrapped + + class UserFilter(FilterSet): class Meta: model = User @@ -53,6 +65,177 @@ class FilterSetContainerDeprecationTests(TestCase): self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) +class MethodFilterDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + # old tests + @silence + def test_filtering(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter(action='filter_username') + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username=value + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_external(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + def filter_username(queryset, value): + return queryset.filter( + username=value + ) + + class F(FilterSet): + username = MethodFilter(action=filter_username) + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_default_attribute_action(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username__contains='ke' + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')],) + self.assertEqual(list(F({'username': 'jake'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + self.assertEqual(list(F({'username': 'aaron'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + + @silence + def test_filtering_default(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + email = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'jake'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'aaron'}).qs), + list(User.objects.all())) + + +class FilterActionDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda x: x) + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filter_using_action(self): + qs = mock.NonCallableMock(spec=[]) + action = mock.Mock(spec=['filter']) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + f = Filter(action=action) + result = f.filter(qs, 'value') + action.assert_called_once_with(qs, 'value') + self.assertNotEqual(qs, result) + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filtering_with_action(self): + User.objects.create(username='alex', status=1) + User.objects.create(username='jacob', status=2) + User.objects.create(username='aaron', status=2) + User.objects.create(username='carl', status=0) + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda qs, value: ( + qs.filter(**{'username__startswith': value}) + )) + + class Meta: + model = User + fields = ['username'] + + f = F({'username': 'a'}, queryset=User.objects.all()) + self.assertQuerysetEqual( + f.qs, ['alex', 'aaron'], lambda o: o.username, False) + + class FilterSetMetaDeprecationTests(TestCase): def test_fields_not_set(self): with warnings.catch_warnings(record=True) as w: diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 691b277..85ca673 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -21,7 +21,6 @@ from django_filters.filters import DateFromToRangeFilter from django_filters.filters import DateTimeFromToRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import DurationFilter -from django_filters.filters import MethodFilter from django_filters.filters import MultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import NumberFilter @@ -892,24 +891,23 @@ class AllValuesMultipleFilterTests(TestCase): list()) -class MethodFilterTests(TestCase): +class FilterMethodTests(TestCase): + + def setUp(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') def test_filtering(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - class F(FilterSet): - username = MethodFilter(action='filter_username') + username = CharFilter(method='filter_username') class Meta: model = User fields = ['username'] - def filter_username(self, queryset, value): - return queryset.filter( - username=value - ) + def filter_username(self, queryset, name, value): + return queryset.filter(**{name: value}) self.assertEqual(list(F().qs), list(User.objects.all())) self.assertEqual(list(F({'username': 'alex'}).qs), @@ -917,18 +915,12 @@ class MethodFilterTests(TestCase): self.assertEqual(list(F({'username': 'jose'}).qs), list()) - def test_filtering_external(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - - def filter_username(queryset, value): - return queryset.filter( - username=value - ) + def test_filtering_callable(self): + def filter_username(queryset, name, value): + return queryset.filter(**{name: value}) class F(FilterSet): - username = MethodFilter(action=filter_username) + username = CharFilter(method=filter_username) class Meta: model = User @@ -940,55 +932,6 @@ class MethodFilterTests(TestCase): self.assertEqual(list(F({'username': 'jose'}).qs), list()) - def test_filtering_default_attribute_action(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')],) - self.assertEqual(list(F({'username': 'jake'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - self.assertEqual(list(F({'username': 'aaron'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - - def test_filtering_default(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - email = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'jake'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'aaron'}).qs), - list(User.objects.all())) - class O2ORelationshipTests(TestCase): @@ -1679,19 +1622,6 @@ class MiscFilterSetTests(TestCase): f = F({'username': 'alex', 'status': '2'}, queryset=qs) self.assertQuerysetEqual(f.qs, [], lambda o: o.pk) - def test_filter_with_action(self): - class F(FilterSet): - username = CharFilter(action=lambda qs, value: ( - qs.filter(**{'username__startswith': value}))) - - class Meta: - model = User - fields = ['username'] - - f = F({'username': 'a'}, queryset=User.objects.all()) - self.assertQuerysetEqual( - f.qs, ['alex', 'aaron'], lambda o: o.username, False) - def test_filter_with_initial(self): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES, initial=1) diff --git a/tests/test_filters.py b/tests/test_filters.py index 8a2f880..76ef5c3 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -195,12 +195,12 @@ class FilterTests(TestCase): qs.filter.assert_called_once_with(somefield__exact='value') self.assertNotEqual(qs, result) - def test_filter_using_action(self): + def test_filter_using_method(self): qs = mock.NonCallableMock(spec=[]) - action = mock.Mock(spec=['filter']) - f = Filter(action=action) + method = mock.Mock() + f = Filter(method=method) result = f.filter(qs, 'value') - action.assert_called_once_with(qs, 'value') + method.assert_called_once_with(qs, None, 'value') self.assertNotEqual(qs, result) def test_filtering_uses_distinct(self): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 9c615c8..1df11e6 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -11,6 +11,7 @@ from django.test import TestCase from django_filters.filterset import FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS from django_filters.filterset import STRICTNESS +from django_filters.filters import Filter from django_filters.filters import BooleanFilter from django_filters.filters import CharFilter from django_filters.filters import NumberFilter @@ -20,6 +21,8 @@ from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import UUIDFilter from django_filters.filters import BaseInFilter from django_filters.filters import BaseRangeFilter +from django_filters.filters import DateRangeFilter +from django_filters.filters import FilterMethod from django_filters.widgets import BooleanWidget @@ -788,6 +791,106 @@ class FilterSetTogetherTests(TestCase): self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk) +# test filter.method here, as it depends on its parent FilterSet +class FilterMethodTests(TestCase): + + def test_none(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method=None) + self.assertIsNone(f.method) + + # passing method=None should not modify filter function + self.assertIs(f.filter, TestFilter.filter) + + def test_method_name(self): + class F(FilterSet): + f = Filter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_callable(self): + def filter_f(qs, name, value): + pass + + class F(FilterSet): + f = Filter(method=filter_f) + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, filter_f) + self.assertEqual(f.filters['f'].filter.method, filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_with_overridden_filter(self): + # Some filter classes override the base filter() method. We need + # to ensure that passing a method argument still works correctly + class F(FilterSet): + f = DateRangeFilter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + + def test_parent_unresolvable(self): + f = Filter(method='filter_f') + with self.assertRaises(AssertionError) as w: + f.filter(User.objects.all(), 0) + + self.assertIn("'None'", str(w.exception)) + self.assertIn('parent', str(w.exception)) + self.assertIn('filter_f', str(w.exception)) + + def test_method_unresolvable(self): + class F(FilterSet): + f = Filter(method='filter_f') + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_uncallable(self): + class F(FilterSet): + f = Filter(method='filter_f') + filter_f = 4 + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_set_unset(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method='filter_f') + self.assertEqual(f.method, 'filter_f') + self.assertIsInstance(f.filter, FilterMethod) + + # setting None should revert to Filter.filter + f.method = None + self.assertIsNone(f.method) + self.assertIs(f.filter, TestFilter.filter) + + @unittest.skip('TODO: remove when relevant deprecations have been completed') class MiscFilterSetTests(TestCase): From 16d07790c26574ced9f2d240d2d2369a1d107b4b Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Aug 2016 14:39:54 -0400 Subject: [PATCH 14/45] Add ref & migration docs, update guide --- docs/migration.txt | 36 ++++++++++++++++++++++ docs/ref/filters.txt | 48 ++++++++++++++++++++++++++--- docs/usage.txt | 72 ++++++++------------------------------------ 3 files changed, 92 insertions(+), 64 deletions(-) diff --git a/docs/migration.txt b/docs/migration.txt index 5f7afb7..f4467f6 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -8,6 +8,42 @@ release has also been created to help with migration. It is compatible with both the existing and new APIs and will raise warnings for deprecated behavior. +MethodFilter and Filter.action replaced by Filter.method +-------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/382 + +The functionality of ``MethodFilter`` and ``Filter.action`` has been merged +together and replaced by the ``Filter.method`` paramater. The ``method`` +parameter takes either a callable or the name of a ``FilterSet`` method. The +signature now takes an additional ``name`` argument that is the name of the +model field to be filtered on. + +Since ``method`` is now a parameter of all filters, inputs are validated and +cleaned by its ``field_class``. The function will receive the cleaned value +instead of the raw value. + +.. code-block:: python + + # 0.x + class UserFilter(FilterSet): + last_login = filters.MethodFilter() + + def filter_last_login(self, qs, value): + # try to convert value to datetime, which may fail. + if value and looks_like_a_date(value): + value = datetime(value) + + return qs.filter(last_login=value}) + + + # 1.0 + class UserFilter(FilterSet): + last_login = filters.CharFilter(method='filter_last_login') + + def filter_last_login(self, qs, name, value): + return qs.filter(**{name: value}) + + QuerySet methods are no longer proxied -------------------------------------- Details: https://github.com/carltongibson/django-filter/pull/440 diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 4546bc6..1282ea4 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -43,13 +43,51 @@ additional ones that django-filter provides which may be useful: * :ref:`RangeWidget ` -- this widget is used with ``RangeFilter`` to generate two form input elements using a single field. -``action`` +.. _filter-method: + +``method`` ~~~~~~~~~~ -An optional callable that tells the filter how to handle the queryset. It -recieves a ``QuerySet`` and the value to filter on and should return a -``Queryset`` that is filtered appropriately. `action` will default to -``filter_{value-of-name-attribute}`` +An optional argument that tells the filter how to handle the queryset. It can +accept either a callable or the name of a method on the ``FilterSet``. The +method receives a ``QuerySet``, the name of the model field to filter on, and +the value to filter with. It should return a ``Queryset`` that is filtered +appropriately. + +The passed in value is validated and cleaned by the filter's ``field_class``, +so raw value transformation and empty value checking should be unnecessary. + +.. code-block:: python + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method='filter_published') + + def filter_published(self, queryset, name, value): + # construct the full lookup expression. + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + # alternatively, it may not be necessary to construct the lookup. + return queryset.filter(published_on__isnull=False) + + class Meta: + model = Book + fields = ['published'] + + + # Callables may also be defined out of the class scope. + def filter_not_empty(queryset, name, value): + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method=filter_not_empty) + + class Meta: + model = Book + fields = ['published'] ``lookup_expr`` ~~~~~~~~~~~~~~~ diff --git a/docs/usage.txt b/docs/usage.txt index 1abfe9c..7e13497 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -236,71 +236,25 @@ default filters for all the models fields of the same kind using } -Custom filtering with MethodFilter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Customize filtering with ``Filter.method`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you want fine control over each individual filter attribute, you can use -the ``MethodFilter`` filter. +You can control the behavior of a filter by specifying a ``method`` to perform +filtering. View more information in the :ref:`method reference `. -By passing in the name of a custom defined filter function as an ``action``, -the filter attribute gets linked to the custom filter function. -Here is an example of overriding the filter function of the -filter attribute ``username`` -:: +.. code-block:: python class F(django_filters.FilterSet): - username = MethodFilter(action='my_custom_filter') + username = MethodFilter(action='my_custom_filter') - class Meta: - model = User - fields = ['username'] + class Meta: + model = User + fields = ['username'] - def my_custom_filter(self, queryset, value): - return queryset.filter( - username=value - ) - - -The filter function can also be defined outside of the filter class scope. -Though you would need to pass in the actual function value, not it's name. -:: - - def my_custom_filter(queryset, value): - return queryset.filter( - username=value - ) - - class F(django_filters.FilterSet): - # Notice: In this case, action accepts a func, not a string - username = MethodFilter(action=my_custom_filter) - - class Meta: - model = User - fields = ['username'] - - -Lastly, when using a ``MethodFilter``, there is no need to define an action. -You may simply do the following and ``filter_username`` will be auto-detected -and used. :: - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - -Under the hood, if ``action`` is not defined, ``django_filter`` -searches for a class method with a name that follows the pattern -``filter_{{ATTRIBUTE_NAME}}``. For example, if the attribute name is -``email``, then the filter class will be scanned for the filter function -``filter_email``. If no action is provided, and no filter class -function is found, then the filter attribute will be left unfiltered. + def my_custom_filter(self, queryset, name, value): + return queryset.filter(**{ + name: value, + }) The view From 0cbb38d2b4435b067f72aa502bfa8d6de28bf899 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 14 Aug 2016 21:49:10 -0400 Subject: [PATCH 15/45] whoops --- docs/usage.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.txt b/docs/usage.txt index 7e13497..3b5d8a3 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -245,7 +245,7 @@ filtering. View more information in the :ref:`method reference `. .. code-block:: python class F(django_filters.FilterSet): - username = MethodFilter(action='my_custom_filter') + username = CharFilter(method='my_custom_filter') class Meta: model = User From f31e4c1465a2c8fd69b2b37db7bc39baa68fe8c8 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 23 Aug 2016 22:15:05 +0200 Subject: [PATCH 16/45] Minor doc fixes --- docs/ref/filters.txt | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 4546bc6..cce1402 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -236,7 +236,7 @@ Matches on a date and time. Used with ``DateTimeField`` by default. Uses ``IsoDateTimeField`` to support filtering on ISO 8601 formatted dates, as are often used in APIs, and are employed by default by Django REST Framework. -Example. :: +Example:: class F(FilterSet): """Filter for Books by date published, using ISO 8601 formatted dates""" @@ -263,10 +263,11 @@ and week designators, e.g. 'P3DT10H22M'). Similar to a ``ChoiceFilter`` except it works with related models, used for ``ForeignKey`` by default. -If automatically instantiated ``ModelChoiceFilter`` will use the default ``QuerySet`` for the -related field. If manually instantiated you **must** provide the ``queryset`` kwarg. +If automatically instantiated, ``ModelChoiceFilter`` will use the default +``QuerySet`` for the related field. If manually instantiated you **must** +provide the ``queryset`` kwarg. -Example. :: +Example:: class F(FilterSet): """Filter for books by author""" @@ -282,9 +283,9 @@ Example. :: Similar to a ``MultipleChoiceFilter`` except it works with related models, used for ``ManyToManyField`` by default. -As with ``ModelChoiceFilter``, if automatically instantiated ``ModelMultipleChoiceFilter`` will use -the default ``QuerySet`` for the related field. If manually instantiated you **must** provide the -``queryset`` kwarg. +As with ``ModelChoiceFilter``, if automatically instantiated, +``ModelMultipleChoiceFilter`` will use the default ``QuerySet`` for the related +field. If manually instantiated you **must** provide the ``queryset`` kwarg. ``NumberFilter`` ~~~~~~~~~~~~~~~~ @@ -478,7 +479,7 @@ this filter class is used in conjunction with another filter class, as this class **only** validates that the incoming value is comma-separated. The secondary filter is then used to validate the individual values. -Example. :: +Example:: class NumberInFilter(BaseInFilter, NumericFilter): pass @@ -505,7 +506,7 @@ This is a base class used for creating RANGE lookup filters. It behaves identically to ``BaseInFilter`` with the exception that it expects only two comma-separated values. -Example. :: +Example:: class NumberRangeFilter(BaseInFilter, NumericFilter): pass @@ -533,7 +534,7 @@ This is a ``Filter`` that will allow you to run a method that exists on the filt this filter is a property of. Set the `action` to a string that will map to a method on the filter set class. `action` will default to ``filter_{value-of-name-attribute}`` -Example. :: +Example:: class F(FilterSet): """Filter for Books by if books are published or not""" From 303bb064cb8fd46be9d9ff3ce12260ee1e58733f Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 23 Aug 2016 22:21:53 +0200 Subject: [PATCH 17/45] Remove lingering test version for Django <1.6 --- tests/test_fields.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 1b1d6b4..dd76151 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -111,26 +111,6 @@ class LookupTypeFieldTests(TestCase): f.clean(['12.34', 'lt']), Lookup(to_d(12.34), 'lt')) - @unittest.skipIf(django.VERSION >= (1, 6), - 'Django 1.6 uses html5 fields') - def test_render(self): - inner = forms.DecimalField() - f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')]) - self.assertHTMLEqual(f.widget.render('price', ''), """ - - """) - self.assertHTMLEqual(f.widget.render('price', ['abc', 'lt']), """ - - """) - - @unittest.skipUnless(django.VERSION >= (1, 6), - 'Django 1.6 uses html5 fields') def test_render_used_html5(self): inner = forms.DecimalField() f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')]) From 40711c5fd01893d4241d8fc6c597f9d2395f6617 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 22 Aug 2016 21:11:51 +0200 Subject: [PATCH 18/45] MultipleChoiceFilter: allow to override get_filter_predicate This allows to use an annotated field easily, without overriding `filter` altogether: class MyMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): def get_filter_predicate(self, v): return {'annotated_field': v.annotated_field} foo = MyMultipleChoiceFilter( to_field_name='annotated_field', queryset=Model.objects.qs_with_annotated_field(), ) --- django_filters/filters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index c3d46c7..b1d4610 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -252,7 +252,7 @@ class MultipleChoiceFilter(Filter): if not self.conjoined: q = Q() for v in set(value): - predicate = {self.name: v} + predicate = self.get_filter_predicate(v) if self.conjoined: qs = self.get_method(qs)(**predicate) else: @@ -263,6 +263,9 @@ class MultipleChoiceFilter(Filter): return qs.distinct() if self.distinct else qs + def get_filter_predicate(self, v): + return {self.name: v} + class DateFilter(Filter): field_class = forms.DateField From a93d5ad2b0f78a28f1e400fc23bfcd7d15d7da53 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 23 Aug 2016 22:01:51 +0200 Subject: [PATCH 19/45] MultipleChoiceFilter: use to_field_name for lookups by default Ref: https://github.com/carltongibson/django-filter/pull/469#issuecomment-241854666 --- django_filters/filters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index b1d4610..068b056 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -264,7 +264,10 @@ class MultipleChoiceFilter(Filter): return qs.distinct() if self.distinct else qs def get_filter_predicate(self, v): - return {self.name: v} + try: + return {self.name: getattr(v, self.field.to_field_name)} + except (AttributeError, TypeError): + return {self.name: v} class DateFilter(Filter): From fb828f8d1bf76848c3631f20cee521831afab3e4 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 23 Aug 2016 22:09:08 +0200 Subject: [PATCH 20/45] Add doc for new Model{Multiple,}ChoiceFilter --- django_filters/filters.py | 5 +++++ docs/ref/filters.txt | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 068b056..a1d33c8 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -211,6 +211,11 @@ class MultipleChoiceFilter(Filter): filtering may be a no-operation. In this case you may wish to avoid the filtering overhead, particularly if using a `distinct` call. + You can override `get_filter_predicate` to use a custom filter. + By default it will use the filter's name for the key, and the value will + be the model object - or in case of passing in `to_field_name` the + value of that attribute on the model. + Set `always_filter` to `False` after instantiation to enable the default `is_noop` test. You can override `is_noop` if you need a different test for your application. diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 176b9c5..9c47c96 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -158,7 +158,15 @@ ModelMultipleChoiceFilter only. ``queryset`` ~~~~~~~~~~~~ -``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg. +``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to +operate on which must be passed as a kwarg. + +``to_field_name`` +~~~~~~~~~~~~~~~~~ + +If you pass in ``to_field_name`` (which gets forwarded to the Django field), +it will be used also in the default ``get_filter_predicate`` implementation +as the model's attribute. Filters @@ -325,6 +333,33 @@ As with ``ModelChoiceFilter``, if automatically instantiated, ``ModelMultipleChoiceFilter`` will use the default ``QuerySet`` for the related field. If manually instantiated you **must** provide the ``queryset`` kwarg. +To use a custom field name for the lookup, you can use ``to_field_name``:: + + class FooFilter(BaseFilterSet): + foo = django_filters.filters.ModelMultipleChoiceFilter( + name='attr__uuid', + to_field_name='uuid', + queryset=Foo.objects.all(), + ) + +If you want to use a custom queryset, e.g. to add annotated fields, this can be +done as follows:: + + class MyMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): + def get_filter_predicate(self, v): + return {'annotated_field': v.annotated_field} + + def filter(self, qs, value): + if value: + qs = qs.qs_with_annotated_field() + qs = super().filter(qs, value) + return qs + + foo = MyMultipleChoiceFilter( + to_field_name='annotated_field', + queryset=Model.objects.qs_with_annotated_field(), + ) + ``NumberFilter`` ~~~~~~~~~~~~~~~~ From 54f31d562f3487513b639c51ca461051af5572ba Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 24 Aug 2016 15:33:10 +0200 Subject: [PATCH 21/45] Add test_filtering_to_field_name --- tests/test_filters.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_filters.py b/tests/test_filters.py index 76ef5c3..4981a3b 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -513,6 +513,21 @@ class ModelMultipleChoiceFilterTests(TestCase): self.assertIsInstance(field, forms.ModelMultipleChoiceField) self.assertEqual(field.queryset, qs) + def test_filtering_to_field_name(self): + qs = User.objects.all() + f = ModelMultipleChoiceFilter(name='first_name', + to_field_name='first_name', + queryset=qs) + user = User.objects.create(first_name='Firstname') + + self.assertEqual(f.get_filter_predicate(user), + {'first_name': 'Firstname'}) + self.assertEqual(f.get_filter_predicate('FilterValue'), + {'first_name': 'FilterValue'}) + + self.assertEqual(list(f.filter(qs, ['Firstname'])), [user]) + self.assertEqual(list(f.filter(qs, [user])), [user]) + class NumberFilterTests(TestCase): From 8e217132ca765b726d08b8f51f25e7851159330a Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 24 Aug 2016 15:41:27 +0200 Subject: [PATCH 22/45] doc: fix typo with Filter.method --- docs/migration.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration.txt b/docs/migration.txt index 1982229..29cb862 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -13,7 +13,7 @@ MethodFilter and Filter.action replaced by Filter.method Details: https://github.com/carltongibson/django-filter/pull/382 The functionality of ``MethodFilter`` and ``Filter.action`` has been merged -together and replaced by the ``Filter.method`` paramater. The ``method`` +together and replaced by the ``Filter.method`` parameter. The ``method`` parameter takes either a callable or the name of a ``FilterSet`` method. The signature now takes an additional ``name`` argument that is the name of the model field to be filtered on. From 3a947446678871cbc964495abedc6601966c318d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 24 Aug 2016 15:42:32 +0200 Subject: [PATCH 23/45] tox: commands: add {posargs} This makes `tox -e py35-django110 -- --help` etc work. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b43f691..0960508 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ basepython = py35: python3.5 -commands = ./runtests.py +commands = ./runtests.py {posargs} setenv = PYTHONDONTWRITEBYTECODE=1 deps = From 8a4837fa60bba06a13ccb46a0668b163f52b46c7 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 24 Aug 2016 22:00:54 +0200 Subject: [PATCH 24/45] fixup! Add test_filtering_to_field_name --- docs/ref/filters.txt | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 9c47c96..3196ad9 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -351,15 +351,34 @@ done as follows:: def filter(self, qs, value): if value: - qs = qs.qs_with_annotated_field() + qs = qs.annotate_with_custom_field() qs = super().filter(qs, value) return qs foo = MyMultipleChoiceFilter( to_field_name='annotated_field', - queryset=Model.objects.qs_with_annotated_field(), + queryset=Model.objects.annotate_with_custom_field(), ) +The ``annotate_with_custom_field`` method would be defined through a custom +QuerySet, which then gets used as the model's manager:: + + class CustomQuerySet(models.QuerySet): + def annotate_with_custom_field(self): + return self.annotate( + custom_field=Case( + When(foo__isnull=False, + then=F('foo__uuid')), + When(bar__isnull=False, + then=F('bar__uuid')), + default=None, + ), + ) + + class MyModel(models.Model): + objects = CustomQuerySet.as_manager() + + ``NumberFilter`` ~~~~~~~~~~~~~~~~ From 3bc42ac21f71420673a878451089693f42f563b6 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 15 Jun 2016 21:16:16 -0400 Subject: [PATCH 25/45] Refactor filter defaults and overrides - Make FILTER_DEFAULTS a class attr. This will allow the DRF FilterSet to easily override the defaults, without relying on filter_overrides. - Move filter_overrides to the meta options, provide deprecation notice. --- django_filters/filterset.py | 92 ++++++++++--------------------------- 1 file changed, 25 insertions(+), 67 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index cd69ddf..bcf9f0d 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -211,30 +211,28 @@ class FilterSetMetaclass(type): FILTER_FOR_DBFIELD_DEFAULTS = { - models.AutoField: { - 'filter_class': NumberFilter - }, - models.CharField: { - 'filter_class': CharFilter - }, - models.TextField: { - 'filter_class': CharFilter - }, - models.BooleanField: { - 'filter_class': BooleanFilter - }, - models.DateField: { - 'filter_class': DateFilter - }, - models.DateTimeField: { - 'filter_class': DateTimeFilter - }, - models.TimeField: { - 'filter_class': TimeFilter - }, - models.DurationField: { - 'filter_class': DurationFilter - }, + models.AutoField: {'filter_class': NumberFilter}, + models.CharField: {'filter_class': CharFilter}, + models.TextField: {'filter_class': CharFilter}, + models.BooleanField: {'filter_class': BooleanFilter}, + models.DateField: {'filter_class': DateFilter}, + models.DateTimeField: {'filter_class': DateTimeFilter}, + models.TimeField: {'filter_class': TimeFilter}, + models.DurationField: {'filter_class': DurationFilter}, + models.DecimalField: {'filter_class': NumberFilter}, + models.SmallIntegerField: {'filter_class': NumberFilter}, + models.IntegerField: {'filter_class': NumberFilter}, + models.PositiveIntegerField: {'filter_class': NumberFilter}, + models.PositiveSmallIntegerField: {'filter_class': NumberFilter}, + models.FloatField: {'filter_class': NumberFilter}, + models.NullBooleanField: {'filter_class': BooleanFilter}, + models.SlugField: {'filter_class': CharFilter}, + models.EmailField: {'filter_class': CharFilter}, + models.FilePathField: {'filter_class': CharFilter}, + models.URLField: {'filter_class': CharFilter}, + models.GenericIPAddressField: {'filter_class': CharFilter}, + models.CommaSeparatedIntegerField: {'filter_class': CharFilter}, + models.UUIDField: {'filter_class': UUIDFilter}, models.OneToOneField: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { @@ -255,52 +253,12 @@ FILTER_FOR_DBFIELD_DEFAULTS = { 'queryset': remote_queryset(f), } }, - models.DecimalField: { - 'filter_class': NumberFilter, - }, - models.SmallIntegerField: { - 'filter_class': NumberFilter, - }, - models.IntegerField: { - 'filter_class': NumberFilter, - }, - models.PositiveIntegerField: { - 'filter_class': NumberFilter, - }, - models.PositiveSmallIntegerField: { - 'filter_class': NumberFilter, - }, - models.FloatField: { - 'filter_class': NumberFilter, - }, - models.NullBooleanField: { - 'filter_class': BooleanFilter, - }, - models.SlugField: { - 'filter_class': CharFilter, - }, - models.EmailField: { - 'filter_class': CharFilter, - }, - models.FilePathField: { - 'filter_class': CharFilter, - }, - models.URLField: { - 'filter_class': CharFilter, - }, - models.GenericIPAddressField: { - 'filter_class': CharFilter, - }, - models.CommaSeparatedIntegerField: { - 'filter_class': CharFilter, - }, - models.UUIDField: { - 'filter_class': UUIDFilter, - }, } class BaseFilterSet(object): + FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS + def __init__(self, data=None, queryset=None, prefix=None, strict=None): self.is_bound = data is not None self.data = data or {} @@ -506,7 +464,7 @@ class BaseFilterSet(object): @classmethod def filter_for_lookup(cls, f, lookup_type): - DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) + DEFAULTS = dict(cls.FILTER_DEFAULTS) if hasattr(cls, '_meta'): DEFAULTS.update(cls._meta.filter_overrides) From 18040dff02cd166e8e8c3d217027167a7aeef396 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 15 Jun 2016 21:59:24 -0400 Subject: [PATCH 26/45] Add rest_framework.FilterSet --- django_filters/rest_framework/__init__.py | 4 ++++ django_filters/rest_framework/filterset.py | 25 ++++++++++++++++++++++ tests/rest_framework/__init__.py | 0 tests/rest_framework/test_filterset.py | 23 ++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 django_filters/rest_framework/__init__.py create mode 100644 django_filters/rest_framework/filterset.py create mode 100644 tests/rest_framework/__init__.py create mode 100644 tests/rest_framework/test_filterset.py diff --git a/django_filters/rest_framework/__init__.py b/django_filters/rest_framework/__init__.py new file mode 100644 index 0000000..404c294 --- /dev/null +++ b/django_filters/rest_framework/__init__.py @@ -0,0 +1,4 @@ +# flake8: noqa +from __future__ import absolute_import +from .filterset import FilterSet +from ..filters import * diff --git a/django_filters/rest_framework/filterset.py b/django_filters/rest_framework/filterset.py new file mode 100644 index 0000000..43a3431 --- /dev/null +++ b/django_filters/rest_framework/filterset.py @@ -0,0 +1,25 @@ + +from __future__ import absolute_import +from copy import deepcopy + +from django.db import models + +from django_filters import filterset +from ..filters import BooleanFilter, IsoDateTimeFilter +from ..widgets import BooleanWidget + + +FILTER_FOR_DBFIELD_DEFAULTS = deepcopy(filterset.FILTER_FOR_DBFIELD_DEFAULTS) +FILTER_FOR_DBFIELD_DEFAULTS.update({ + models.DateTimeField: {'filter_class': IsoDateTimeFilter}, + models.BooleanField: { + 'filter_class': BooleanFilter, + 'extra': lambda f: { + 'widget': BooleanWidget, + }, + }, +}) + + +class FilterSet(filterset.FilterSet): + FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS diff --git a/tests/rest_framework/__init__.py b/tests/rest_framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rest_framework/test_filterset.py b/tests/rest_framework/test_filterset.py new file mode 100644 index 0000000..021af19 --- /dev/null +++ b/tests/rest_framework/test_filterset.py @@ -0,0 +1,23 @@ + +from django.test import TestCase + +from django_filters.rest_framework import FilterSet +from django_filters.filters import BooleanFilter, IsoDateTimeFilter +from django_filters.widgets import BooleanWidget + +from ..models import User, Article + + +class FilterSetFilterForFieldTests(TestCase): + + def test_isodatetimefilter(self): + field = Article._meta.get_field('published') + result = FilterSet.filter_for_field(field, 'published') + self.assertIsInstance(result, IsoDateTimeFilter) + self.assertEqual(result.name, 'published') + + def test_booleanfilter_widget(self): + field = User._meta.get_field('is_active') + result = FilterSet.filter_for_field(field, 'is_active') + self.assertIsInstance(result, BooleanFilter) + self.assertEqual(result.widget, BooleanWidget) From b3f23ef7e047f75b73da50368a437819276ede16 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 15 Jun 2016 22:45:54 -0400 Subject: [PATCH 27/45] Copy DjangoFilterBackend from DRF --- django_filters/compat.py | 7 + django_filters/rest_framework/__init__.py | 1 + django_filters/rest_framework/backends.py | 95 +++++ .../rest_framework/crispy_form.html | 5 + .../django_filters/rest_framework/form.html | 6 + tests/rest_framework/models.py | 28 ++ tests/rest_framework/test_backends.py | 372 ++++++++++++++++++ tests/settings.py | 1 + 8 files changed, 515 insertions(+) create mode 100644 django_filters/rest_framework/backends.py create mode 100644 django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html create mode 100644 django_filters/rest_framework/templates/django_filters/rest_framework/form.html create mode 100644 tests/rest_framework/models.py create mode 100644 tests/rest_framework/test_backends.py diff --git a/django_filters/compat.py b/django_filters/compat.py index 296551d..ddc0253 100644 --- a/django_filters/compat.py +++ b/django_filters/compat.py @@ -2,6 +2,13 @@ import django +# django-crispy-forms is optional +try: + import crispy_forms +except ImportError: + crispy_forms = None + + def remote_field(field): """ https://docs.djangoproject.com/en/1.9/releases/1.9/#field-rel-changes diff --git a/django_filters/rest_framework/__init__.py b/django_filters/rest_framework/__init__.py index 404c294..7a2b76f 100644 --- a/django_filters/rest_framework/__init__.py +++ b/django_filters/rest_framework/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa from __future__ import absolute_import +from .backends import DjangoFilterBackend from .filterset import FilterSet from ..filters import * diff --git a/django_filters/rest_framework/backends.py b/django_filters/rest_framework/backends.py new file mode 100644 index 0000000..cc09129 --- /dev/null +++ b/django_filters/rest_framework/backends.py @@ -0,0 +1,95 @@ + +from __future__ import absolute_import + +from django.conf import settings +from django.template import loader +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import compat +from rest_framework.filters import BaseFilterBackend + +from ..compat import crispy_forms +from . import filterset + + +if 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms: + from crispy_forms.helper import FormHelper + from crispy_forms.layout import Layout, Submit + + class FilterSet(filterset.FilterSet): + def __init__(self, *args, **kwargs): + super(FilterSet, self).__init__(*args, **kwargs) + for field in self.form.fields.values(): + field.help_text = None + + layout_components = list(self.form.fields.keys()) + [ + Submit('', _('Submit'), css_class='btn-default'), + ] + + helper = FormHelper() + helper.form_method = 'GET' + helper.template_pack = 'bootstrap3' + helper.layout = Layout(*layout_components) + + self.form.helper = helper + + filter_template = 'django_filters/rest_framework/crispy_form.html' + +else: + class FilterSet(filterset.FilterSet): + def __init__(self, *args, **kwargs): + super(FilterSet, self).__init__(*args, **kwargs) + for field in self.form.fields.values(): + field.help_text = None + + filter_template = 'django_filters/rest_framework/form.html' + + +class DjangoFilterBackend(BaseFilterBackend): + default_filter_set = FilterSet + template = filter_template + + def get_filter_class(self, view, queryset=None): + """ + Return the django-filters `FilterSet` used to filter the queryset. + """ + filter_class = getattr(view, 'filter_class', None) + filter_fields = getattr(view, 'filter_fields', None) + + if filter_class: + filter_model = filter_class.Meta.model + + assert issubclass(queryset.model, filter_model), \ + 'FilterSet model %s does not match queryset model %s' % \ + (filter_model, queryset.model) + + return filter_class + + if filter_fields: + class AutoFilterSet(self.default_filter_set): + class Meta: + model = queryset.model + fields = filter_fields + + return AutoFilterSet + + return None + + def filter_queryset(self, request, queryset, view): + filter_class = self.get_filter_class(view, queryset) + + if filter_class: + return filter_class(request.query_params, queryset=queryset).qs + + return queryset + + def to_html(self, request, queryset, view): + filter_class = self.get_filter_class(view, queryset) + if not filter_class: + return None + filter_instance = filter_class(request.query_params, queryset=queryset) + context = { + 'filter': filter_instance + } + template = loader.get_template(self.template) + return compat.template_render(template, context) diff --git a/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html b/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html new file mode 100644 index 0000000..171767c --- /dev/null +++ b/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html @@ -0,0 +1,5 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +

{% trans "Field filters" %}

+{% crispy filter.form %} diff --git a/django_filters/rest_framework/templates/django_filters/rest_framework/form.html b/django_filters/rest_framework/templates/django_filters/rest_framework/form.html new file mode 100644 index 0000000..b116e35 --- /dev/null +++ b/django_filters/rest_framework/templates/django_filters/rest_framework/form.html @@ -0,0 +1,6 @@ +{% load i18n %} +

{% trans "Field filters" %}

+
+ {{ filter.form.as_p }} + +
diff --git a/tests/rest_framework/models.py b/tests/rest_framework/models.py new file mode 100644 index 0000000..4be18b1 --- /dev/null +++ b/tests/rest_framework/models.py @@ -0,0 +1,28 @@ + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class BasicModel(models.Model): + text = models.CharField( + max_length=100, + verbose_name=_("Text comes here"), + help_text=_("Text description.") + ) + + +class BaseFilterableItem(models.Model): + text = models.CharField(max_length=100) + + +class FilterableItem(BaseFilterableItem): + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + +class DjangoFilterOrderingModel(models.Model): + date = models.DateField() + text = models.CharField(max_length=10) + + class Meta: + ordering = ['-date'] diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py new file mode 100644 index 0000000..7be9919 --- /dev/null +++ b/tests/rest_framework/test_backends.py @@ -0,0 +1,372 @@ +from __future__ import unicode_literals + +import datetime +from decimal import Decimal + +from django.conf.urls import url +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.test.utils import override_settings +from django.utils.dateparse import parse_date + +from rest_framework import generics, serializers, status +from rest_framework.test import APIRequestFactory + +from django_filters import filters +from django_filters.rest_framework import DjangoFilterBackend, FilterSet + +from .models import BaseFilterableItem, BasicModel, FilterableItem, DjangoFilterOrderingModel + +factory = APIRequestFactory() + + +class FilterableItemSerializer(serializers.ModelSerializer): + class Meta: + model = FilterableItem + fields = '__all__' + + +# Basic filter on a list view. +class FilterFieldsRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_fields = ['decimal', 'date'] + filter_backends = (DjangoFilterBackend,) + + +# These class are used to test a filter class. +class SeveralFieldsFilter(FilterSet): + text = filters.CharFilter(lookup_type='icontains') + decimal = filters.NumberFilter(lookup_type='lt') + date = filters.DateFilter(lookup_type='gt') + + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterClassRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + +# These classes are used to test a misconfigured filter class. +class MisconfiguredFilter(FilterSet): + text = filters.CharFilter(lookup_type='icontains') + + class Meta: + model = BasicModel + fields = ['text'] + + +class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = MisconfiguredFilter + filter_backends = (DjangoFilterBackend,) + + +class FilterClassDetailView(generics.RetrieveAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + +# These classes are used to test base model filter support +class BaseFilterableItemFilter(FilterSet): + text = filters.CharFilter() + + class Meta: + model = BaseFilterableItem + + +class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = BaseFilterableItemFilter + filter_backends = (DjangoFilterBackend,) + + +# Regression test for #814 +class FilterFieldsQuerysetView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_fields = ['decimal', 'date'] + filter_backends = (DjangoFilterBackend,) + + +class GetQuerysetView(generics.ListCreateAPIView): + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + def get_queryset(self): + return FilterableItem.objects.all() + + +urlpatterns = [ + url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), + url(r'^$', FilterClassRootView.as_view(), name='root-view'), + url(r'^get-queryset/$', GetQuerysetView.as_view(), name='get-queryset-view'), +] + + +class CommonFilteringTestCase(TestCase): + def _serialize_object(self, obj): + return {'id': obj.id, 'text': obj.text, 'decimal': str(obj.decimal), 'date': obj.date.isoformat()} + + def setUp(self): + """ + Create 10 FilterableItem instances. + """ + 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 + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + self._serialize_object(obj) + for obj in self.objects.all() + ] + + +class IntegrationTestFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered list views. + """ + + 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.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) == search_decimal] + self.assertEqual(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.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) == search_date] + self.assertEqual(response.data, expected_data) + + def test_filter_with_queryset(self): + """ + Regression test for #814. + """ + view = FilterFieldsQuerysetView.as_view() + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) == search_decimal] + self.assertEqual(response.data, expected_data) + + def test_filter_with_get_queryset_only(self): + """ + Regression test for #834. + """ + view = GetQuerysetView.as_view() + request = factory.get('/get-queryset/') + view(request).render() + # Used to raise "issubclass() arg 2 must be a class or tuple of classes" + # here when neither `model' nor `queryset' was specified. + + 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.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter set with 'lt' in the filter class works. + search_decimal = Decimal('4.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) < search_decimal] + self.assertEqual(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.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) > search_date] + self.assertEqual(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.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if search_text in f['text'].lower()] + self.assertEqual(response.data, expected_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + request = factory.get('/', { + 'decimal': '%s' % (search_decimal,), + 'date': '%s' % (search_date,) + }) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) > search_date and + Decimal(f['decimal']) < search_decimal] + self.assertEqual(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) + + def test_base_model_filter(self): + """ + The `get_filter_class` model checks should allow base model filters. + """ + view = BaseFilterableItemFilterRootView.as_view() + + request = factory.get('/?text=aaa') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_unknown_filter(self): + """ + 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.assertEqual(response.status_code, status.HTTP_200_OK) + + +@override_settings(ROOT_URLCONF='tests.rest_framework.test_backends') +class IntegrationTestDetailFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered detail views. + """ + def _get_url(self, item): + return reverse('detail-view', kwargs=dict(pk=item.pk)) + + def test_get_filtered_detail_view(self): + """ + GET requests to filtered RetrieveAPIView that have a filter_class set + should return filtered results. + """ + item = self.objects.all()[0] + data = self._serialize_object(item) + + # Basic test with no filter. + response = self.client.get(self._get_url(item)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, data) + + # Tests that the decimal filter set that should fail. + search_decimal = Decimal('4.25') + high_item = self.objects.filter(decimal__gt=search_decimal)[0] + response = self.client.get( + '{url}'.format(url=self._get_url(high_item)), + {'decimal': '{param}'.format(param=search_decimal)}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Tests that the decimal filter set that should succeed. + search_decimal = Decimal('4.25') + low_item = self.objects.filter(decimal__lt=search_decimal)[0] + low_item_data = self._serialize_object(low_item) + response = self.client.get( + '{url}'.format(url=self._get_url(low_item)), + {'decimal': '{param}'.format(param=search_decimal)}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, low_item_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + valid_item = self.objects.filter(decimal__lt=search_decimal, date__gt=search_date)[0] + valid_item_data = self._serialize_object(valid_item) + response = self.client.get( + '{url}'.format(url=self._get_url(valid_item)), { + 'decimal': '{decimal}'.format(decimal=search_decimal), + 'date': '{date}'.format(date=search_date) + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, valid_item_data) + + +class DjangoFilterOrderingSerializer(serializers.ModelSerializer): + class Meta: + model = DjangoFilterOrderingModel + fields = '__all__' + + +class DjangoFilterOrderingTests(TestCase): + def setUp(self): + data = [{ + 'date': datetime.date(2012, 10, 8), + 'text': 'abc' + }, { + 'date': datetime.date(2013, 10, 8), + 'text': 'bcd' + }, { + 'date': datetime.date(2014, 10, 8), + 'text': 'cde' + }] + + for d in data: + DjangoFilterOrderingModel.objects.create(**d) + + def test_default_ordering(self): + class DjangoFilterOrderingView(generics.ListAPIView): + serializer_class = DjangoFilterOrderingSerializer + queryset = DjangoFilterOrderingModel.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ['text'] + ordering = ('-date',) + + view = DjangoFilterOrderingView.as_view() + request = factory.get('/') + response = view(request) + + self.assertEqual( + response.data, + [ + {'id': 3, 'date': '2014-10-08', 'text': 'cde'}, + {'id': 2, 'date': '2013-10-08', 'text': 'bcd'}, + {'id': 1, 'date': '2012-10-08', 'text': 'abc'} + ] + ) diff --git a/tests/settings.py b/tests/settings.py index 6f7e1c8..962471f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -10,6 +10,7 @@ INSTALLED_APPS = ( 'django.contrib.auth', 'django_filters', 'tests', + 'tests.rest_framework', ) ROOT_URLCONF = 'tests.urls' From d19c2b67b7a7cc2ed4fd8d01922a5b917e408ab3 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 15 Jun 2016 22:47:11 -0400 Subject: [PATCH 28/45] Add DRF 3.3, 3.4 support --- .travis.yml | 19 ++++++++++--------- README.rst | 1 + 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7f5b15b..eb604c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,13 +11,14 @@ python: - "3.5" env: - - DJANGO='https://github.com/django/django/archive/master.tar.gz' - - DJANGO='django>=1.10.0,<1.11.0' - - DJANGO='django>=1.9.0,<1.10.0' - - DJANGO='django>=1.8.0,<1.9.0' + - DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' + - DJANGO="django>=1.10.0,<1.11.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.3,<3.4" install: - - travis_retry pip install $DJANGO + - travis_retry pip install $DJANGO $REST_FRAMEWORK - travis_retry pip install -r requirements/travis-ci.txt script: @@ -30,11 +31,11 @@ notifications: matrix: exclude: - python: "3.3" - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' + env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' - python: "3.3" - env: DJANGO='django>=1.10.0,<1.11.0' + env: DJANGO="django>=1.10.0,<1.11.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" - python: "3.3" - env: DJANGO='django>=1.9.0,<1.10.0' + env: DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" allow_failures: - - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' + - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' fast_finish: true diff --git a/README.rst b/README.rst index 6a3e53f..6de34f1 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,7 @@ Requirements * Python 2.7, 3.3, 3.4, 3.5 * Django 1.8, 1.9, 1.10 +* DRF 3.3 (Django 1.8 only), 3.4 Installation ------------ From f665b4c46302ac114771fbe225104a01d421cf5e Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Wed, 24 Aug 2016 22:13:42 +0200 Subject: [PATCH 29/45] Adjust `deprecate` calls after rebase --- django_filters/filterset.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index bcf9f0d..b6760e7 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -180,6 +180,14 @@ class FilterSetMetaclass(type): opts = new_class._meta = FilterSetOptions( getattr(new_class, 'Meta', None)) + if hasattr(new_class, 'strict'): + deprecate('strict has been deprecated. Use Meta.strict instead.') + new_class._meta.strict = new_class.strict + + if hasattr(new_class, 'order_by_field'): + deprecate('order_by_field has been moved to the Meta class.') + new_class._meta.order_by_field = new_class.order_by_field + if hasattr(new_class, 'filter_overrides'): deprecate('filter_overrides has been moved to the Meta class.') new_class._meta.filter_overrides = new_class.filter_overrides @@ -197,14 +205,6 @@ class FilterSetMetaclass(type): raise TypeError("Meta.fields contains a field that isn't defined " "on this FilterSet: {}".format(not_defined)) - if hasattr(new_class, 'strict'): - deprecate('strict has been deprecated. Use Meta.strict instead.') - new_class._meta.strict = new_class.strict - - if hasattr(new_class, 'order_by_field'): - deprecate('order_by_field has been moved to the Meta class.') - new_class._meta.order_by_field = new_class.order_by_field - new_class.declared_filters = declared_filters new_class.base_filters = filters return new_class From 8f9f423941f0b77d63b6b6d859c4ecaca037bc07 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Wed, 24 Aug 2016 22:27:18 +0200 Subject: [PATCH 30/45] Add DRF to test requirements --- requirements/test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/test.txt b/requirements/test.txt index a5d50f1..787570b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,3 +3,4 @@ coverage==3.7.1 django-discover-runner mock sphinx-autobuild +djangorestframework From 31c24c8f5262e6b216c50e6b661b7d75efe74c33 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 4 Sep 2016 00:14:06 -0400 Subject: [PATCH 31/45] Add bash code blocks --- docs/index.txt | 1 + docs/install.txt | 2 ++ docs/tests.txt | 12 ++++++++++++ 3 files changed, 15 insertions(+) diff --git a/docs/index.txt b/docs/index.txt index 0f00a70..5d0c5c7 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -14,6 +14,7 @@ Contents: install usage + rest_framework ref/filterset ref/filters ref/fields diff --git a/docs/install.txt b/docs/install.txt index 6265241..477d062 100644 --- a/docs/install.txt +++ b/docs/install.txt @@ -3,6 +3,8 @@ Installing django-filter Install with pip: +.. code-block:: bash + pip install django-filter And then add ``'django_filters'`` to your ``INSTALLED_APPS``. diff --git a/docs/tests.txt b/docs/tests.txt index 1a42f47..fa22bf6 100644 --- a/docs/tests.txt +++ b/docs/tests.txt @@ -18,6 +18,8 @@ Set up a virtualenv for the test suite Run the following to create a new virtualenv to run the test suite in:: +.. code-block:: bash + virtualenv django-filter-tests cd django-filter-tests . bin/activate @@ -27,10 +29,14 @@ Get a copy of django-filter Get the django-filter source code using the following command:: +.. code-block:: bash + git clone https://github.com/alex/django-filter.git Switch to the django-filter directory:: +.. code-block:: bash + cd django-filter Install the test dependencies @@ -39,10 +45,14 @@ Install the test dependencies Run the following to install the test dependencies within the virutalenv:: +.. code-block:: bash + pip install -r requirements/test.txt Run the django-filter tests:: +.. code-block:: bash + python runtests.py @@ -52,5 +62,7 @@ Testing all supported versions You can also use the excellent tox testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run:: +.. code-block:: bash + tox From 285d7f725281c978e9a40591a6e77f146de7a812 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sun, 4 Sep 2016 00:14:29 -0400 Subject: [PATCH 32/45] Add adapted DRF docs --- docs/img/form.png | Bin 0 -> 13678 bytes docs/rest_framework.txt | 111 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 docs/img/form.png create mode 100644 docs/rest_framework.txt diff --git a/docs/img/form.png b/docs/img/form.png new file mode 100644 index 0000000000000000000000000000000000000000..5baef32f7e89ce973cb097544810756d26c29c95 GIT binary patch literal 13678 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. + + +.. code-block:: python + + from rest_framework import generics + from django_filters import rest_framework as filters + from myapp import Product + + + class ProductList(generics.ListAPIView): + queryset = Product.objects.all() + filter_backends = (filters.DjangoFilterBackend,) + filter_fields = ('category', 'in_stock') + + + # Equivalent FilterSet: + class ProductFilter(filters.FilterSet): + class Meta: + model = Product + fields = ('category', 'in_stock') + + +Crispy Forms +------------ +If you are using DRF's browsable API or admin API you may also want to install `django-crispy-forms`, which will enhance the presentation of the filter forms in HTML views, by allowing them to render Bootstrap 3 HTML. Note that this isn't actively supported, although pull requests for bug fixes are welcome. + +.. code-block:: bash + + pip install django-crispy-forms + +With crispy forms installed and added to Django's `INSTALLED_APPS`, the browsable API will present a filtering control for `DjangoFilterBackend`, like so: + +.. image:: img/form.png From 569efd4e4675e01cf86cd9e7707d180ff62dd1f0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 30 Jun 2016 15:38:43 -0400 Subject: [PATCH 33/45] Rework ordering into a filter --- django_filters/filters.py | 101 +++++++++++++++++++++++++++++++++- django_filters/filterset.py | 107 ++++++++++++++++-------------------- django_filters/utils.py | 5 ++ tests/test_filters.py | 103 ++++++++++++++++++++++++++++++++++ tests/test_filterset.py | 34 ------------ 5 files changed, 254 insertions(+), 96 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index a1d33c8..2c3196d 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import warnings +from collections import OrderedDict from datetime import timedelta from django import forms @@ -10,6 +11,7 @@ from django.db.models.sql.constants import QUERY_TERMS from django.db.models.constants import LOOKUP_SEP from django.conf import settings from django.utils import six +from django.utils.itercompat import is_iterable from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ @@ -17,7 +19,7 @@ from .fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField ) -from .utils import deprecate +from .utils import deprecate, pretty_name __all__ = [ @@ -39,6 +41,7 @@ __all__ = [ 'MultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', + 'OrderingFilter', 'RangeFilter', 'TimeFilter', 'TimeRangeFilter', @@ -489,6 +492,102 @@ class BaseRangeFilter(BaseCSVFilter): super(BaseRangeFilter, self).__init__(*args, **kwargs) +class OrderingFilter(BaseCSVFilter, ChoiceFilter): + """ + Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts + two additional arguments that are used to build the ordering choices. + + * ``fields`` is a mapping of {model field name: parameter name}. The + parameter names are exposed in the choices and mask/alias the field + names used in the ``order_by()`` call. Similar to field ``choices``, + ``fields`` accepts the 'list of two-tuples' syntax that retains order. + ``fields`` may also just be an iterable of strings. In this case, the + field names simply double as the exposed parameter names. + + * ``field_labels`` is an optional argument that allows you to customize + the display label for the corresponding parameter. It accepts a mapping + of {field name: human readable label}. Keep in mind that the key is the + field name, and not the exposed parameter name. + + Additionally, you can just provide your own ``choices`` if you require + explicit control over the exposed options. For example, when you might + want to disable descending sort options. + + This filter is also CSV-based, and accepts multiple ordering params. The + default select widget does not enable the use of this, but it is useful + for APIs. + + """ + descending_fmt = _('%s (descending)') + + def __init__(self, *args, **kwargs): + """ + ``fields`` may be either a mapping or an iterable. + ``field_labels`` must be a map of field names to display labels + """ + fields = kwargs.pop('fields', {}) + fields = self.normalize_fields(fields) + field_labels = kwargs.pop('field_labels', {}) + + self.param_map = {v: k for k, v in fields.items()} + + if 'choices' not in kwargs: + kwargs['choices'] = self.build_choices(fields, field_labels) + + kwargs.setdefault('label', _('Ordering')) + super(OrderingFilter, self).__init__(*args, **kwargs) + + def get_ordering_value(self, param): + descending = param.startswith('-') + param = param[1:] if descending else param + field_name = self.param_map.get(param, param) + + return "-%s" % field_name if descending else field_name + + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + ordering = [self.get_ordering_value(param) for param in value] + return qs.order_by(*ordering) + + @classmethod + def normalize_fields(cls, fields): + """ + Normalize the fields into an ordered map of {field name: param name} + """ + # fields is a mapping, copy into new OrderedDict + if isinstance(fields, dict): + return OrderedDict(fields) + + # convert iterable of values => iterable of pairs (field name, param name) + assert is_iterable(fields), \ + "'fields' must be an iterable (e.g., a list, tuple, or mapping)." + + # fields is an iterable of field names + assert all(isinstance(field, six.string_types) or + is_iterable(field) and len(field) == 2 # may need to be wrapped in parens + for field in fields), \ + "'fields' must contain strings or (field name, param name) pairs." + + return OrderedDict([ + (f, f) if isinstance(f, six.string_types) else f for f in fields + ]) + + def build_choices(self, fields, labels): + ascending = [ + (param, labels.get(field, pretty_name(param))) + for field, param in fields.items() + ] + descending = [ + ('-%s' % pair[0], self.descending_fmt % pair[1]) + for pair in ascending + ] + + # interleave the ascending and descending choices + return [val for pair in zip(ascending, descending) for val in pair] + + class MethodFilter(Filter): """ This filter will allow you to run a method that exists on the filterset class diff --git a/django_filters/filterset.py b/django_filters/filterset.py index b6760e7..e6dd5ae 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -2,12 +2,10 @@ from __future__ import absolute_import from __future__ import unicode_literals import copy -import re from collections import OrderedDict from django import forms from django.forms.forms import NON_FIELD_ERRORS -from django.core.validators import EMPTY_VALUES from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.fields.related import ForeignObjectRel @@ -19,8 +17,8 @@ from .compat import remote_field, remote_queryset from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, - DurationFilter) -from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate + DurationFilter, OrderingFilter) +from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, pretty_name, deprecate ORDER_BY_FIELD = 'o' @@ -205,6 +203,11 @@ class FilterSetMetaclass(type): raise TypeError("Meta.fields contains a field that isn't defined " "on this FilterSet: {}".format(not_defined)) + # check key existence instead of setdefault - prevents unnecessary filter construction + order_by_field = new_class._meta.order_by_field + if opts.order_by and order_by_field not in filters: + filters[order_by_field] = new_class.get_ordering_filter(opts, filters) + new_class.declared_filters = declared_filters new_class.base_filters = filters return new_class @@ -330,23 +333,6 @@ class BaseFilterSet(object): if value is not None: # valid & clean data qs = filter_.filter(qs, value) - if self._meta.order_by: - order_field = self.form.fields[self._meta.order_by_field] - data = self.form[self._meta.order_by_field].data - ordered_value = None - try: - ordered_value = order_field.clean(data) - except forms.ValidationError: - pass - - # With a None-queryset, ordering must be enforced (#84). - if (ordered_value in EMPTY_VALUES and - self.strict == STRICTNESS.RETURN_NO_RESULTS): - ordered_value = self.form.fields[self._meta.order_by_field].choices[0][0] - - if ordered_value: - qs = qs.order_by(*self.get_order_by(ordered_value)) - self._qs = qs return self._qs @@ -357,7 +343,7 @@ class BaseFilterSet(object): fields = OrderedDict([ (name, filter_.field) for name, filter_ in six.iteritems(self.filters)]) - fields[self._meta.order_by_field] = self.ordering_field + Form = type(str('%sForm' % self.__class__.__name__), (self._meta.form,), fields) if self._meta.together: @@ -368,49 +354,48 @@ class BaseFilterSet(object): self._form = Form(prefix=self.form_prefix) return self._form - def get_ordering_field(self): - if self._meta.order_by: - if isinstance(self._meta.order_by, (list, tuple)): - if isinstance(self._meta.order_by[0], (list, tuple)): - # e.g. (('field', 'Display name'), ...) - choices = [(f[0], f[1]) for f in self._meta.order_by] - else: - choices = [] - for f in self._meta.order_by: - if f[0] == '-': - label = _('%s (descending)' % capfirst(f[1:])) - else: - label = capfirst(f) - choices.append((f, label)) + @classmethod + def get_ordering_filter(cls, opts, filters): + assert not isinstance(opts.fields, dict), \ + "'order_by' is not compatible with the 'fields' dict syntax. Use OrderingFilter instead." + + def display_text(name, fltr): + """ + ``name`` is the filter's attribute name on the FilterSet + ``text`` is the current display text, which is either the ``name`` + or an explicitly assigned label. + """ + # TODO: use `fltr._label` in label-improvements branch + text = fltr.label or name.lstrip('-') + if name.startswith('-'): + text = _('%s (descending)' % text) + + return pretty_name(text) + + if isinstance(opts.order_by, (list, tuple)): + + # e.g. (('field', 'Display name'), ...) + if isinstance(opts.order_by[0], (list, tuple)): + choices = [(f[0], f[1]) for f in opts.order_by] + fields = {filters.get(f[0].lstrip('-')).name: f[0] for f in opts.order_by} + return OrderingFilter(choices=choices, fields=fields) + + # e.g. ('field1', 'field2', ...) else: - # add asc and desc field names - # use the filter's label if provided - choices = [] - for f, fltr in self.filters.items(): - choices.extend([ - (f, fltr.label or capfirst(f)), - ("-%s" % (f), _('%s (descending)' % (fltr.label or capfirst(f)))) - ]) - return forms.ChoiceField(label=_("Ordering"), required=False, - choices=choices) + # (filter name, filter instance) + order_by = [(f, filters.get(f.lstrip('-'))) for f in opts.order_by] - @property - def ordering_field(self): - if not hasattr(self, '_ordering_field'): - self._ordering_field = self.get_ordering_field() - return self._ordering_field + # preference filter label over attribute name + choices = [(f, display_text(f, fltr)) for f, fltr in order_by] + fields = {fltr.name: f for f, fltr in order_by} + return OrderingFilter(choices=choices, fields=fields) - def get_order_by(self, order_choice): - re_ordering_field = re.compile(r'(?P\-?)(?P.*)') - m = re.match(re_ordering_field, order_choice) - inverted = m.group('inverse') - filter_api_name = m.group('field') + # opts.order_by = True + order_by = filters.items() - _filter = self.filters.get(filter_api_name, None) - - if _filter and filter_api_name != _filter.name: - return [inverted + _filter.name] - return [order_choice] + fields = [(fltr.name, f) for f, fltr in order_by] + labels = {f: display_text(f, fltr) for f, fltr in order_by} + return OrderingFilter(fields=fields, field_labels=labels) @classmethod def filters_for_model(cls, model, opts): diff --git a/django_filters/utils.py b/django_filters/utils.py index 510d91c..8212599 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -9,6 +9,11 @@ from django.db.models.fields import FieldDoesNotExist from django.db.models.fields.related import ForeignObjectRel from django.utils import six, timezone +try: + from django.forms.utils import pretty_name +except ImportError: # Django 1.8 + from django.forms.forms import pretty_name + from .compat import remote_field, remote_model from .exceptions import FieldLookupError diff --git a/tests/test_filters.py b/tests/test_filters.py index 4981a3b..b360a5d 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +from collections import OrderedDict from datetime import date, time, timedelta, datetime import mock import warnings @@ -41,6 +42,7 @@ from django_filters.filters import ( BaseInFilter, BaseRangeFilter, UUIDFilter, + OrderingFilter, LOOKUP_TYPES) from tests.models import Book, User @@ -994,3 +996,104 @@ class BaseRangeFilterTests(TestCase): f = NumberInFilter() f.filter(qs, [1, 2]) qs.filter.assert_called_once_with(None__range=[1, 2]) + + +class OrderingFilterTests(TestCase): + def test_default_field(self): + f = OrderingFilter() + field = f.field + self.assertIsInstance(field, forms.ChoiceField) + + def test_filtering(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + f.filter(qs, ['a', 'b']) + qs.order_by.assert_called_once_with('a', 'b') + + def test_filtering_descending(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + f.filter(qs, ['-a']) + qs.order_by.assert_called_once_with('-a') + + def test_filtering_with_fields(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter(fields={'a': 'b'}) + f.filter(qs, ['b', '-b']) + qs.order_by.assert_called_once_with('a', '-a') + + def test_filtering_skipped_with_none_value(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + result = f.filter(qs, None) + self.assertEqual(qs, result) + + def test_choices_unaltered(self): + # provided 'choices' should not be altered when 'fields' is present + f = OrderingFilter( + choices=(('a', 'A'), ('b', 'B')), + fields=(('a', 'c'), ('b', 'd')), + ) + + self.assertSequenceEqual(f.field.choices, ( + ('a', 'A'), + ('b', 'B'), + )) + + def test_choices_from_fields(self): + f = OrderingFilter( + fields=(('a', 'c'), ('b', 'd')), + ) + + self.assertSequenceEqual(f.field.choices, ( + ('c', 'C'), + ('-c', 'C (descending)'), + ('d', 'D'), + ('-d', 'D (descending)'), + )) + + def test_field_labels(self): + f = OrderingFilter( + fields=(('a', 'c'), ('b', 'd')), + field_labels={'a': 'foo'}, + ) + + self.assertSequenceEqual(f.field.choices, ( + ('c', 'foo'), + ('-c', 'foo (descending)'), + ('d', 'D'), + ('-d', 'D (descending)'), + )) + + def test_normalize_fields(self): + f = OrderingFilter.normalize_fields + O = OrderedDict + + self.assertIn('a', f({'a': 'b'})) + + self.assertEqual( + f(O([('a', 'b'), ('c', 'd')])), + O([('a', 'b'), ('c', 'd')]) + ) + + self.assertEqual( + f([('a', 'b'), ('c', 'd')]), + O([('a', 'b'), ('c', 'd')]) + ) + + self.assertEqual( + f(['a', 'b']), + O([('a', 'a'), ('b', 'b')]) + ) + + with self.assertRaises(AssertionError) as ctx: + f(None) + self.assertEqual(str(ctx.exception), "'fields' must be an iterable (e.g., a list, tuple, or mapping).") + + with self.assertRaises(AssertionError) as ctx: + f([('a', 'b', 'c')]) + self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.") + + with self.assertRaises(AssertionError) as ctx: + f([0, 1, 2]) + self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.") diff --git a/tests/test_filterset.py b/tests/test_filterset.py index d315d27..64fe871 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -575,17 +575,6 @@ class FilterSetOrderingTests(TestCase): # user_ids = list(User.objects.all().values_list('pk', flat=True)) self.qs = User.objects.all().order_by('id') - def test_ordering_when_unbound(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F(queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - def test_ordering(self): class F(FilterSet): class Meta: @@ -725,29 +714,6 @@ class FilterSetOrderingTests(TestCase): self.assertQuerysetEqual( f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - def test_custom_ordering(self): - - class F(FilterSet): - debug = True - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', 'status'] - - def get_order_by(self, order_choice): - if order_choice == 'status': - return ['status', 'username'] - return super(F, self).get_order_by(order_choice) - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username) - class FilterSetTogetherTests(TestCase): From 72a94066674c5af2afea50d4fae7e98cdd66d583 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 30 Jun 2016 16:02:01 -0400 Subject: [PATCH 34/45] Deprecate Meta.order_by, order_by_field, methods --- django_filters/filterset.py | 15 ++ tests/test_deprecations.py | 326 ++++++++++++++++++++++++++++++++++++ tests/test_filterset.py | 152 ----------------- tests/test_forms.py | 119 ------------- 4 files changed, 341 insertions(+), 271 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index e6dd5ae..efae5c3 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -151,6 +151,12 @@ class FilterSetOptions(object): self.filter_overrides = getattr(options, 'filter_overrides', {}) + if hasattr(options, 'order_by'): + deprecate('Meta.order_by has been deprecated.', 1) + + if hasattr(options, 'order_by_field'): + deprecate('Meta.order_by_field has been deprecated.', 1) + self.order_by = getattr(options, 'order_by', False) self.order_by_field = getattr(options, 'order_by_field', ORDER_BY_FIELD) @@ -190,6 +196,14 @@ class FilterSetMetaclass(type): deprecate('filter_overrides has been moved to the Meta class.') new_class._meta.filter_overrides = new_class.filter_overrides + assert not hasattr(new_class, 'get_order_by'), \ + 'get_order_by() has been deprecated. Subclass OrderingFilter and override .filter() instead. ' \ + 'See: https://django-filter.readthedocs.io/en/latest/migration.html"' + + assert not hasattr(new_class, 'get_ordering_field'), \ + 'get_ordering_field() has been deprecated. Use OrderingFilter instead. ' \ + 'See: https://django-filter.readthedocs.io/en/latest/migration.html"' + # TODO: replace with deprecations # if opts.model and opts.fields: if opts.model: @@ -203,6 +217,7 @@ class FilterSetMetaclass(type): raise TypeError("Meta.fields contains a field that isn't defined " "on this FilterSet: {}".format(not_defined)) + # TODO: remove with deprecations # check key existence instead of setdefault - prevents unnecessary filter construction order_by_field = new_class._meta.order_by_field if opts.order_by and order_by_field not in filters: diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 77a6fd8..db970f3 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -2,10 +2,12 @@ import functools import warnings import mock +from django.core.exceptions import ValidationError from django.test import TestCase from django_filters import FilterSet from django_filters.filters import Filter, CharFilter, MethodFilter +from django_filters.filterset import STRICTNESS from .models import User from .models import NetworkSetting from .models import SubnetMaskField @@ -366,3 +368,327 @@ class OrderByFieldDeprecationTests(TestCase): order_by_field = 'field' self.assertEqual(F._meta.order_by_field, 'field') + + +class OrderByDeprecationTests(TestCase): + def test_order_by_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + order_by = True + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_order_by_field_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + order_by_field = 'field' + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_get_order_by_assertion(self): + with self.assertRaises(AssertionError): + class F(FilterSet): + def get_order_by(self): + pass + + def test_get_ordering_field_assertion(self): + with self.assertRaises(AssertionError): + class F(FilterSet): + def get_ordering_field(self): + pass + + +class DeprecatedOrderingFilterSetTests(TestCase): + def setUp(self): + self.alex = User.objects.create(username='alex', status=1) + self.jacob = User.objects.create(username='jacob', status=2) + self.aaron = User.objects.create(username='aaron', status=2) + self.carl = User.objects.create(username='carl', status=0) + self.qs = User.objects.all().order_by('id') + + # old filterset tests + @silence + def test_ordering(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['username', 'status'] + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + f = F({'o': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, [], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + strict = STRICTNESS.IGNORE + + self.assertFalse(F._meta.strict) + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + strict = STRICTNESS.RAISE_VALIDATION_ERROR + + f = F({'o': 'username'}, queryset=self.qs) + with self.assertRaises(ValidationError) as excinfo: + f.qs.all() + self.assertEqual(excinfo.exception.message_dict, + {'o': ['Select a valid choice. username is not one ' + 'of the available choices.']}) + + # No default order_by should get applied. + f = F({}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) + + @silence + def test_ordering_on_different_field(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + f = F({'o': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_uses_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F({'o': 'account'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + @silence + def test_reverted_ordering_uses_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F({'o': '-account'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_with_overridden_field_name(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + order_by_field = 'order' + + f = F({'order': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_descending_set(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['username', '-username'] + + f = F({'o': '-username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_descending_unset(self): + """ Test ordering descending works when order_by=True. """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F({'o': '-username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + +class DeprecatedOrderingFormTests(TestCase): + @silence + def test_ordering(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + + f = F().form + self.assertEqual(len(f.fields), 3) + self.assertIn('o', f.fields) + self.assertEqual(f.fields['o'].choices, [('status', 'Status')]) + + @silence + def test_ordering_uses_all_fields(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_uses_filter_label(self): + class F(FilterSet): + username = CharFilter(label='Account') + + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Account'), + ('-username', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_uses_explicit_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('account', 'Account'), + ('-account', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_overridden_field_name(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + order_by_field = 'order' + + f = F().form + self.assertNotIn('o', f.fields) + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [('status', 'Status')]) + + @silence + def test_ordering_with_overridden_field_name_and_descending(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status', '-status'] + order_by_field = 'order' + + f = F().form + self.assertNotIn('o', f.fields) + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_overridden_field_name_and_using_all_fields(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + order_by_field = 'order' + + f = F().form + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_custom_display_names(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = [('status', 'Current status')] + + f = F().form + self.assertEqual( + f.fields['o'].choices, [('status', 'Current status')]) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 64fe871..477191a 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -4,13 +4,11 @@ import mock import unittest import django -from django.core.exceptions import ValidationError from django.db import models from django.test import TestCase from django_filters.filterset import FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS -from django_filters.filterset import STRICTNESS from django_filters.filters import Filter from django_filters.filters import BooleanFilter from django_filters.filters import CharFilter @@ -565,156 +563,6 @@ class FilterSetInstantiationTests(TestCase): self.assertEqual(f.queryset, m) -class FilterSetOrderingTests(TestCase): - - def setUp(self): - self.alex = User.objects.create(username='alex', status=1) - self.jacob = User.objects.create(username='jacob', status=2) - self.aaron = User.objects.create(username='aaron', status=2) - self.carl = User.objects.create(username='carl', status=0) - # user_ids = list(User.objects.all().values_list('pk', flat=True)) - self.qs = User.objects.all().order_by('id') - - def test_ordering(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', 'status'] - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_on_unknown_value(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, [], lambda o: o.username) - - def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - strict = STRICTNESS.IGNORE - - self.assertFalse(F._meta.strict) - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) - - def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - strict = STRICTNESS.RAISE_VALIDATION_ERROR - - f = F({'o': 'username'}, queryset=self.qs) - with self.assertRaises(ValidationError) as excinfo: - f.qs.all() - self.assertEqual(excinfo.exception.message_dict, - {'o': ['Select a valid choice. username is not one ' - 'of the available choices.']}) - - # No default order_by should get applied. - f = F({}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) - - def test_ordering_on_different_field(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_uses_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F({'o': 'account'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - def test_reverted_ordering_uses_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F({'o': '-account'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - def test_ordering_with_overridden_field_name(self): - """ - Set the `order_by_field` on the filterset and ensure that the - field name is respected. - """ - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - order_by_field = 'order' - - f = F({'order': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_descending_set(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', '-username'] - - f = F({'o': '-username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - def test_ordering_descending_unset(self): - """ Test ordering descending works when order_by=True. """ - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F({'o': '-username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - class FilterSetTogetherTests(TestCase): def setUp(self): diff --git a/tests/test_forms.py b/tests/test_forms.py index bd790ae..b63380d 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -164,125 +164,6 @@ class FilterSetFormTests(TestCase): self.assertTrue(f.is_bound) self.assertEqual(f.data, {'title': 'Some book'}) - def test_ordering(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F().form - self.assertEqual(len(f.fields), 3) - self.assertIn('o', f.fields) - self.assertEqual(f.fields['o'].choices, [('status', 'Status')]) - - def test_ordering_uses_all_fields(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, [ - ('username', 'Username'), - ('-username', 'Username (descending)'), - ('status', 'Status'), - ('-status', 'Status (descending)')]) - - def test_ordering_uses_filter_label(self): - class F(FilterSet): - username = CharFilter(label='Account') - - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, [ - ('username', 'Account'), - ('-username', 'Account (descending)'), - ('status', 'Status'), - ('-status', 'Status (descending)')]) - - def test_ordering_uses_explicit_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, [ - ('account', 'Account'), - ('-account', 'Account (descending)'), - ('status', 'Status'), - ('-status', 'Status (descending)')]) - - def test_ordering_with_overridden_field_name(self): - """ - Set the `order_by_field` on the filterset and ensure that the - field name is respected. - """ - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - order_by_field = 'order' - - f = F().form - self.assertNotIn('o', f.fields) - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, [('status', 'Status')]) - - def test_ordering_with_overridden_field_name_and_descending(self): - """ - Set the `order_by_field` on the filterset and ensure that the - field name is respected. - """ - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status', '-status'] - order_by_field = 'order' - - f = F().form - self.assertNotIn('o', f.fields) - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_with_overridden_field_name_and_using_all_fields(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - order_by_field = 'order' - - f = F().form - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, [ - ('username', 'Username'), - ('-username', 'Username (descending)'), - ('status', 'Status'), - ('-status', 'Status (descending)')]) - - def test_ordering_with_custom_display_names(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = [('status', 'Current status')] - - f = F().form - self.assertEqual( - f.fields['o'].choices, [('status', 'Current status')]) - def test_limit_choices_to(self): User.objects.create(username='inactive', is_active=False, status=REGULAR) User.objects.create(username='active', is_active=True, status=REGULAR) From a18b016bce80da0a1e848c7695a584fcfada3ef8 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 3 Sep 2016 21:22:19 -0400 Subject: [PATCH 35/45] Add OrderingFilter ref & migration docs --- docs/migration.txt | 35 ++++++++++++++++++++ docs/ref/filters.txt | 78 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/docs/migration.txt b/docs/migration.txt index 29cb862..bb76008 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -118,3 +118,38 @@ potential conflicts with declared filter names. This includes: filter_overrides = {} strict = STRICTNESS.RAISE_VALIDATION_ERROR order_by_field = 'order' + + +FilterSet ordering replaced by OrderingFilter +--------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/472 + +The FilterSet ordering options and methods have been deprecated and replaced +by :ref:`OrderingFilter `. Deprecated options include: + + +* ``Meta.order_by`` +* ``Meta.order_by_field`` + +These options retain backwards compatibility with the following caveats: + +* ``order_by`` asserts that ``Meta.fields`` is not using the dict syntax. This + previously was undefined behavior, however the migration code is unable to + support it. +* Prior, if no ordering was specified in the request, the FilterSet implicitly + filtered by the first param in the ``order_by`` option. This behavior cannot + be easily emulated but can be fixed by ensuring that the passed in queryset + explicitly calls ``.order_by()``. + + .. code-block:: python + + filterset = MyFilterSet(queryset=MyModel.objects.order_by('field')) + +The following methods are deprecated and will raise an assertion if present +on the FilterSet: + +* ``.get_order_by()`` +* ``.get_ordering_field()`` + +To fix this, simply remove the methods from your class. You can subclass +``OrderingFilter`` to migrate any custom logic. diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 3196ad9..1f8ad2e 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -640,3 +640,81 @@ Example:: class Meta: model = Book fields = ['published'] + + +.. _ordering-filter: + +``OrderingFilter`` +~~~~~~~~~~~~~~~~~~ + +Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts +two additional arguments that are used to build the ordering choices. + +* ``fields`` is a mapping of {model field name: parameter name}. The + parameter names are exposed in the choices and mask/alias the field + names used in the ``order_by()`` call. Similar to field ``choices``, + ``fields`` accepts the 'list of two-tuples' syntax that retains order. + ``fields`` may also just be an iterable of strings. In this case, the + field names simply double as the exposed parameter names. + +* ``field_labels`` is an optional argument that allows you to customize + the display label for the corresponding parameter. It accepts a mapping + of {field name: human readable label}. Keep in mind that the key is the + field name, and not the exposed parameter name. + +.. code-block:: python + + class UserFilter(FilterSet): + account = CharFilter(name='username') + status = NumberFilter(name='status') + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ('username', 'account'), + ('first_name', 'first_name'), + ('last_name', 'last_name'), + }, + + # labels do not need to retain order + field_labels={ + 'username': 'User account', + } + ) + + class Meta: + model = User + fields = ['first_name', 'last_name'] + + >>> UserFilter().filter['o'].field.choices + [ + ('account', 'User account'), + ('-account', 'User account (descending)'), + ('first_name', 'First name'), + ('-first_name', 'First name (descending)'), + ('last_name', 'Last name'), + ('-last_name', 'Last name (descending)'), + ] + +Additionally, you can just provide your own ``choices`` if you require +explicit control over the exposed options. For example, when you might +want to disable descending sort options. + +.. code-block:: python + + class UserFilter(FilterSet): + account = CharFilter(name='username') + status = NumberFilter(name='status') + + o = OrderingFilter( + choices=( + ('account', 'Account'), + ), + fields={ + 'username': 'account', + }, + ) + +This filter is also CSV-based, and accepts multiple ordering params. The +default select widget does not enable the use of this, but it is useful +for APIs. From e7bb7d9d100b81a7339da75b4895b64a614194b7 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 14 Sep 2016 13:20:40 -0400 Subject: [PATCH 36/45] Apply crispy layout to base DRF FilterSet --- django_filters/compat.py | 3 ++ django_filters/rest_framework/backends.py | 37 ++-------------------- django_filters/rest_framework/filterset.py | 20 ++++++++++++ 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/django_filters/compat.py b/django_filters/compat.py index ddc0253..41197b1 100644 --- a/django_filters/compat.py +++ b/django_filters/compat.py @@ -1,5 +1,6 @@ import django +from django.conf import settings # django-crispy-forms is optional @@ -8,6 +9,8 @@ try: except ImportError: crispy_forms = None +is_crispy = 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms + def remote_field(field): """ diff --git a/django_filters/rest_framework/backends.py b/django_filters/rest_framework/backends.py index cc09129..6655b44 100644 --- a/django_filters/rest_framework/backends.py +++ b/django_filters/rest_framework/backends.py @@ -1,52 +1,21 @@ from __future__ import absolute_import -from django.conf import settings from django.template import loader -from django.utils.translation import ugettext_lazy as _ - -from rest_framework import compat from rest_framework.filters import BaseFilterBackend -from ..compat import crispy_forms +from .. import compat from . import filterset -if 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms: - from crispy_forms.helper import FormHelper - from crispy_forms.layout import Layout, Submit - - class FilterSet(filterset.FilterSet): - def __init__(self, *args, **kwargs): - super(FilterSet, self).__init__(*args, **kwargs) - for field in self.form.fields.values(): - field.help_text = None - - layout_components = list(self.form.fields.keys()) + [ - Submit('', _('Submit'), css_class='btn-default'), - ] - - helper = FormHelper() - helper.form_method = 'GET' - helper.template_pack = 'bootstrap3' - helper.layout = Layout(*layout_components) - - self.form.helper = helper - +if compat.is_crispy: filter_template = 'django_filters/rest_framework/crispy_form.html' - else: - class FilterSet(filterset.FilterSet): - def __init__(self, *args, **kwargs): - super(FilterSet, self).__init__(*args, **kwargs) - for field in self.form.fields.values(): - field.help_text = None - filter_template = 'django_filters/rest_framework/form.html' class DjangoFilterBackend(BaseFilterBackend): - default_filter_set = FilterSet + default_filter_set = filterset.FilterSet template = filter_template def get_filter_class(self, view, queryset=None): diff --git a/django_filters/rest_framework/filterset.py b/django_filters/rest_framework/filterset.py index 43a3431..44de380 100644 --- a/django_filters/rest_framework/filterset.py +++ b/django_filters/rest_framework/filterset.py @@ -3,10 +3,16 @@ from __future__ import absolute_import from copy import deepcopy from django.db import models +from django.utils.translation import ugettext_lazy as _ from django_filters import filterset from ..filters import BooleanFilter, IsoDateTimeFilter from ..widgets import BooleanWidget +from .. import compat + +if compat.is_crispy: + from crispy_forms.helper import FormHelper + from crispy_forms.layout import Layout, Submit FILTER_FOR_DBFIELD_DEFAULTS = deepcopy(filterset.FILTER_FOR_DBFIELD_DEFAULTS) @@ -23,3 +29,17 @@ FILTER_FOR_DBFIELD_DEFAULTS.update({ class FilterSet(filterset.FilterSet): FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS + + def __init__(self, *args, **kwargs): + super(FilterSet, self).__init__(*args, **kwargs) + + if compat.is_crispy: + layout_components = list(self.form.fields.keys()) + [ + Submit('', _('Submit'), css_class='btn-default'), + ] + helper = FormHelper() + helper.form_method = 'GET' + helper.template_pack = 'bootstrap3' + helper.layout = Layout(*layout_components) + + self.form.helper = helper From 0fead30ab51833150484594a14ee1867f2cc4cc7 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 14 Sep 2016 20:07:48 -0400 Subject: [PATCH 37/45] Fix various deprecations --- django_filters/compat.py | 6 ++++++ django_filters/widgets.py | 4 +++- tests/rest_framework/test_backends.py | 16 +++++++++++----- tests/settings.py | 4 +--- tests/test_forms.py | 4 ++-- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/django_filters/compat.py b/django_filters/compat.py index 41197b1..e7fa88b 100644 --- a/django_filters/compat.py +++ b/django_filters/compat.py @@ -32,3 +32,9 @@ def remote_queryset(field): limit_choices_to = field.get_limit_choices_to() return model._default_manager.complex_filter(limit_choices_to) + + +def format_value(widget, value): + if django.VERSION >= (1, 10): + return widget.format_value(value) + return widget._format_value(value) diff --git a/django_filters/widgets.py b/django_filters/widgets.py index 076dc66..f910a80 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -16,6 +16,8 @@ from django.utils.safestring import mark_safe from django.utils.six import string_types from django.utils.translation import ugettext as _ +from .compat import format_value + class LinkWidget(forms.Widget): def __init__(self, attrs=None, choices=()): @@ -151,7 +153,7 @@ class CSVWidget(forms.TextInput): def render(self, name, value, attrs=None): if self._isiterable(value): - value = [force_text(self._format_value(v)) for v in value] + value = [force_text(format_value(self, v)) for v in value] value = ','.join(list(value)) return super(CSVWidget, self).render(name, value, attrs) diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py index 7be9919..2a569ab 100644 --- a/tests/rest_framework/test_backends.py +++ b/tests/rest_framework/test_backends.py @@ -4,11 +4,16 @@ import datetime from decimal import Decimal from django.conf.urls import url -from django.core.urlresolvers import reverse from django.test import TestCase from django.test.utils import override_settings from django.utils.dateparse import parse_date +try: + from django.urls import reverse +except ImportError: + # Django < 1.10 compatibility + from django.core.urlresolvers import reverse + from rest_framework import generics, serializers, status from rest_framework.test import APIRequestFactory @@ -36,9 +41,9 @@ class FilterFieldsRootView(generics.ListCreateAPIView): # These class are used to test a filter class. class SeveralFieldsFilter(FilterSet): - text = filters.CharFilter(lookup_type='icontains') - decimal = filters.NumberFilter(lookup_type='lt') - date = filters.DateFilter(lookup_type='gt') + text = filters.CharFilter(lookup_expr='icontains') + decimal = filters.NumberFilter(lookup_expr='lt') + date = filters.DateFilter(lookup_expr='gt') class Meta: model = FilterableItem @@ -54,7 +59,7 @@ class FilterClassRootView(generics.ListCreateAPIView): # These classes are used to test a misconfigured filter class. class MisconfiguredFilter(FilterSet): - text = filters.CharFilter(lookup_type='icontains') + text = filters.CharFilter(lookup_expr='icontains') class Meta: model = BasicModel @@ -81,6 +86,7 @@ class BaseFilterableItemFilter(FilterSet): class Meta: model = BaseFilterableItem + fields = '__all__' class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): diff --git a/tests/settings.py b/tests/settings.py index 962471f..ae26e3e 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -25,6 +25,4 @@ TEMPLATES = [{ }] -SILENCED_SYSTEM_CHECKS = [ - '1_7.W001', # Unset MIDDLEWARE_CLASSES warning -] +MIDDLEWARE = [] diff --git a/tests/test_forms.py b/tests/test_forms.py index b63380d..a3da8c9 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -174,9 +174,9 @@ class FilterSetFormTests(TestCase): model = ManagerGroup fields = ['users', 'manager'] f = F().form - self.assertEquals( + self.assertEqual( list(f.fields['users'].choices), [(2, 'active')] ) - self.assertEquals( + self.assertEqual( list(f.fields['manager'].choices), [('', '---------'), (3, 'manager')] ) From 73b2228d777d9d97ed622b9b14a6110bf5ba4c7f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 16 Sep 2016 01:11:27 -0400 Subject: [PATCH 38/45] Add DRF BooleanFilter --- django_filters/rest_framework/__init__.py | 2 +- django_filters/rest_framework/filters.py | 10 ++++++++++ django_filters/rest_framework/filterset.py | 10 ++-------- tests/rest_framework/test_filters.py | 15 +++++++++++++++ tests/rest_framework/test_filterset.py | 7 +++---- 5 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 django_filters/rest_framework/filters.py create mode 100644 tests/rest_framework/test_filters.py diff --git a/django_filters/rest_framework/__init__.py b/django_filters/rest_framework/__init__.py index 7a2b76f..11af6d5 100644 --- a/django_filters/rest_framework/__init__.py +++ b/django_filters/rest_framework/__init__.py @@ -2,4 +2,4 @@ from __future__ import absolute_import from .backends import DjangoFilterBackend from .filterset import FilterSet -from ..filters import * +from .filters import * diff --git a/django_filters/rest_framework/filters.py b/django_filters/rest_framework/filters.py new file mode 100644 index 0000000..5ebae48 --- /dev/null +++ b/django_filters/rest_framework/filters.py @@ -0,0 +1,10 @@ + +from ..filters import * +from ..widgets import BooleanWidget + + +class BooleanFilter(BooleanFilter): + def __init__(self, *args, **kwargs): + kwargs.setdefault('widget', BooleanWidget) + + super(BooleanFilter, self).__init__(*args, **kwargs) diff --git a/django_filters/rest_framework/filterset.py b/django_filters/rest_framework/filterset.py index 44de380..c28c114 100644 --- a/django_filters/rest_framework/filterset.py +++ b/django_filters/rest_framework/filterset.py @@ -6,8 +6,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django_filters import filterset -from ..filters import BooleanFilter, IsoDateTimeFilter -from ..widgets import BooleanWidget +from .filters import BooleanFilter, IsoDateTimeFilter from .. import compat if compat.is_crispy: @@ -18,12 +17,7 @@ if compat.is_crispy: FILTER_FOR_DBFIELD_DEFAULTS = deepcopy(filterset.FILTER_FOR_DBFIELD_DEFAULTS) FILTER_FOR_DBFIELD_DEFAULTS.update({ models.DateTimeField: {'filter_class': IsoDateTimeFilter}, - models.BooleanField: { - 'filter_class': BooleanFilter, - 'extra': lambda f: { - 'widget': BooleanWidget, - }, - }, + models.BooleanField: {'filter_class': BooleanFilter}, }) diff --git a/tests/rest_framework/test_filters.py b/tests/rest_framework/test_filters.py new file mode 100644 index 0000000..872094e --- /dev/null +++ b/tests/rest_framework/test_filters.py @@ -0,0 +1,15 @@ + +from django.test import TestCase + +from django_filters.rest_framework import filters +from django_filters.widgets import BooleanWidget + + +class BooleanFilterTests(TestCase): + + def test_widget(self): + # Ensure that `BooleanFilter` uses the correct widget when importing + # from `rest_framework.filters`. + f = filters.BooleanFilter() + + self.assertEqual(f.widget, BooleanWidget) diff --git a/tests/rest_framework/test_filterset.py b/tests/rest_framework/test_filterset.py index 021af19..db815af 100644 --- a/tests/rest_framework/test_filterset.py +++ b/tests/rest_framework/test_filterset.py @@ -1,8 +1,7 @@ from django.test import TestCase -from django_filters.rest_framework import FilterSet -from django_filters.filters import BooleanFilter, IsoDateTimeFilter +from django_filters.rest_framework import FilterSet, filters from django_filters.widgets import BooleanWidget from ..models import User, Article @@ -13,11 +12,11 @@ class FilterSetFilterForFieldTests(TestCase): def test_isodatetimefilter(self): field = Article._meta.get_field('published') result = FilterSet.filter_for_field(field, 'published') - self.assertIsInstance(result, IsoDateTimeFilter) + self.assertIsInstance(result, filters.IsoDateTimeFilter) self.assertEqual(result.name, 'published') def test_booleanfilter_widget(self): field = User._meta.get_field('is_active') result = FilterSet.filter_for_field(field, 'is_active') - self.assertIsInstance(result, BooleanFilter) + self.assertIsInstance(result, filters.BooleanFilter) self.assertEqual(result.widget, BooleanWidget) From 58114084c4b12bb44370f2e4188218b8fa7b22a4 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 14 Sep 2016 16:21:38 -0400 Subject: [PATCH 39/45] Add filters conf, add deprecation warnings --- django_filters/conf.py | 106 +++++++++++++++++++++++++++++++++++++ django_filters/filters.py | 6 +-- tests/settings.py | 6 +++ tests/test_conf.py | 64 ++++++++++++++++++++++ tests/test_deprecations.py | 26 ++++++++- 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 django_filters/conf.py create mode 100644 tests/test_conf.py diff --git a/django_filters/conf.py b/django_filters/conf.py new file mode 100644 index 0000000..8537f69 --- /dev/null +++ b/django_filters/conf.py @@ -0,0 +1,106 @@ + +from django.conf import settings as dj_settings +from django.core.signals import setting_changed +from django.utils.translation import ugettext_lazy as _ + +from .utils import deprecate + + +DEFAULTS = { + 'HELP_TEXT_FILTER': True, + 'HELP_TEXT_EXCLUDE': True, + 'VERBOSE_LOOKUPS': { + # transforms don't need to be verbose, since their expressions are chained + 'date': _('date'), + 'year': _('year'), + 'month': _('month'), + 'day': _('day'), + 'week_day': _('week day'), + 'hour': _('hour'), + 'minute': _('minute'), + 'second': _('second'), + + # standard lookups + 'exact': _(''), + 'iexact': _(''), + 'contains': _('contains'), + 'icontains': _('contains'), + 'in': _('is in'), + 'gt': _('is greater than'), + 'gte': _('is greater than or equal to'), + 'lt': _('is less than'), + 'lte': _('is less than or equal to'), + 'startswith': _('starts with'), + 'istartswith': _('starts with'), + 'endswith': _('ends with'), + 'iendswith': _('ends with'), + 'range': _('is in range'), + 'isnull': _(''), + 'regex': _('matches regex'), + 'iregex': _('matches regex'), + 'search': _('search'), + + # postgres lookups + 'contained_by': _('is contained by'), + 'overlap': _('overlaps'), + 'has_key': _('has key'), + 'has_keys': _('has keys'), + 'has_any_keys': _('has any keys'), + 'trigram_similar': _('search'), + }, +} + + +DEPRECATED_SETTINGS = [ + 'HELP_TEXT_FILTER', + 'HELP_TEXT_EXCLUDE' +] + + +class Settings(object): + + def __init__(self): + for setting in DEFAULTS: + value = self.get_setting(setting) + setattr(self, setting, value) + + def VERBOSE_LOOKUPS(): + """ + VERBOSE_LOOKUPS accepts a dictionary of {terms: verbose expressions} + or a zero-argument callable that returns a dictionary. + """ + def fget(self): + if callable(self._VERBOSE_LOOKUPS): + self._VERBOSE_LOOKUPS = self._VERBOSE_LOOKUPS() + return self._VERBOSE_LOOKUPS + + def fset(self, value): + self._VERBOSE_LOOKUPS = value + + return locals() + VERBOSE_LOOKUPS = property(**VERBOSE_LOOKUPS()) + + def get_setting(self, setting): + django_setting = 'FILTERS_%s' % setting + + if setting in DEPRECATED_SETTINGS and hasattr(dj_settings, django_setting): + deprecate("The '%s' setting has been deprecated." % django_setting) + + return getattr(dj_settings, django_setting, DEFAULTS[setting]) + + def change_setting(self, setting, value, enter, **kwargs): + if not setting.startswith('FILTERS_'): + return + setting = setting[8:] # strip 'FILTERS_' + + # ensure a valid app setting is being overridden + if setting not in DEFAULTS: + return + + # if exiting, refetch the value from settings. + value = value if enter else self.get_setting(setting) + setattr(self, setting, value) + + +settings = Settings() +setting_changed.connect(settings.change_setting) diff --git a/django_filters/filters.py b/django_filters/filters.py index 2c3196d..1111cf0 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -9,12 +9,12 @@ from django import forms from django.db.models import Q from django.db.models.sql.constants import QUERY_TERMS from django.db.models.constants import LOOKUP_SEP -from django.conf import settings from django.utils import six from django.utils.itercompat import is_iterable from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ +from .conf import settings from .fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField @@ -132,9 +132,9 @@ class Filter(object): if not hasattr(self, '_field'): help_text = self.extra.pop('help_text', None) if help_text is None: - if self.exclude and getattr(settings, "FILTERS_HELP_TEXT_EXCLUDE", True): + if self.exclude and settings.HELP_TEXT_EXCLUDE: help_text = _('This is an exclusion filter') - elif not self.exclude and getattr(settings, "FILTERS_HELP_TEXT_FILTER", True): + elif not self.exclude and settings.HELP_TEXT_FILTER: help_text = _('Filter') else: help_text = '' diff --git a/tests/settings.py b/tests/settings.py index ae26e3e..13f9013 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -26,3 +26,9 @@ TEMPLATES = [{ MIDDLEWARE = [] + + +# help verify that DEFAULTS is importable from conf. +def FILTERS_VERBOSE_LOOKUPS(): + from django_filters.conf import DEFAULTS + return DEFAULTS['VERBOSE_LOOKUPS'] diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..f5806d6 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,64 @@ + +from django.test import TestCase, override_settings + +from django_filters.conf import settings + + +class DefaultSettingsTests(TestCase): + + def test_verbose_loookups(self): + self.assertIsInstance(settings.VERBOSE_LOOKUPS, dict) + self.assertIn('exact', settings.VERBOSE_LOOKUPS) + + def test_help_text_filter(self): + self.assertTrue(settings.HELP_TEXT_FILTER) + + def test_help_text_exclude(self): + self.assertTrue(settings.HELP_TEXT_EXCLUDE) + + +class OverrideSettingsTests(TestCase): + + def test_attribute_override(self): + self.assertIsInstance(settings.VERBOSE_LOOKUPS, dict) + + original = settings.VERBOSE_LOOKUPS + + with override_settings(FILTERS_VERBOSE_LOOKUPS=None): + self.assertIsNone(settings.VERBOSE_LOOKUPS) + + self.assertIs(settings.VERBOSE_LOOKUPS, original) + + def test_missing_attribute_override(self): + # ensure that changed setting behaves correctly when + # not originally present in the user's settings. + from django.conf import settings as dj_settings + self.assertFalse(hasattr(dj_settings, 'FILTERS_HELP_TEXT_FILTER')) + + # Default value + self.assertTrue(settings.HELP_TEXT_FILTER) + + with override_settings(FILTERS_HELP_TEXT_FILTER=None): + self.assertIsNone(settings.HELP_TEXT_FILTER) + + # Revert to default + self.assertTrue(settings.HELP_TEXT_FILTER) + + def test_non_filters_setting(self): + self.assertFalse(hasattr(settings, 'USE_TZ')) + + with override_settings(USE_TZ=False): + self.assertFalse(hasattr(settings, 'USE_TZ')) + + self.assertFalse(hasattr(settings, 'USE_TZ')) + + def test_non_existent_setting(self): + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) + + with override_settings(FILTERS_FOOBAR='blah'): + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) + + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index db970f3..b7871bb 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -3,9 +3,10 @@ import functools import warnings import mock from django.core.exceptions import ValidationError -from django.test import TestCase +from django.test import TestCase, override_settings from django_filters import FilterSet +from django_filters.conf import Settings from django_filters.filters import Filter, CharFilter, MethodFilter from django_filters.filterset import STRICTNESS from .models import User @@ -692,3 +693,26 @@ class DeprecatedOrderingFormTests(TestCase): f = F().form self.assertEqual( f.fields['o'].choices, [('status', 'Current status')]) + + +class DeprecatedSettingsTests(TestCase): + + def test_filter_help_text(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + with override_settings(FILTERS_HELP_TEXT_FILTER=False): + Settings() + + self.assertEqual(len(w), 1) + self.assertIn("The 'FILTERS_HELP_TEXT_FILTER' setting has been deprecated.", str(w[0].message)) + + def test_exclude_help_text(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + with override_settings(FILTERS_HELP_TEXT_EXCLUDE=False): + Settings() + + self.assertEqual(len(w), 1) + self.assertIn("The 'FILTERS_HELP_TEXT_EXCLUDE' setting has been deprecated.", str(w[0].message)) From 8151cda319658008011619a40ad6f0fe19dbc224 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 14 Sep 2016 16:30:08 -0400 Subject: [PATCH 40/45] Add settings deprecation docs --- docs/migration.txt | 10 ++++++++++ docs/ref/settings.txt | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/docs/migration.txt b/docs/migration.txt index bb76008..aa2f8ec 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -153,3 +153,13 @@ on the FilterSet: To fix this, simply remove the methods from your class. You can subclass ``OrderingFilter`` to migrate any custom logic. + + +Deprecated ``FILTERS_HELP_TEXT_FILTER`` and ``FILTERS_HELP_TEXT_EXCLUDE`` +------------------------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/437 + +Generated filter labels in 1.0 will be more descriptive, including humanized +text about the lookup being performed and if the filter is an exclusion filter. + +These settings will no longer have an effect and will be removed in the 1.0 release. diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 760442d..be84085 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -16,9 +16,13 @@ Default: ``True`` Enable / disable the default field ``help_text`` on filters where ``exclude=True`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. +.. note:: This setting is pending deprecation. + FILTERS_HELP_TEXT_FILTER ------------------------ Default: ``True`` Enable / disable the default field ``help_text`` on filters where ``exclude=False`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. + +.. note:: This setting is pending deprecation. From cff0b7c05e36a96626d9938b2a6c9cf20e4d6953 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 17 Sep 2016 15:06:00 -0400 Subject: [PATCH 41/45] Add warnings build, update requirements - coverage 3.7.1 => 4.0 (since py 3.2 is deprecated now) - pytz 2015 => 2016 (fix deprecation warnings) - remove maintainer list from test (speed up dep install) --- .travis.yml | 8 ++++++-- requirements/maintainer.txt | 3 ++- requirements/test.txt | 7 ++----- requirements/travis-ci.txt | 4 +++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index eb604c7..d49d8fa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,11 +18,11 @@ env: - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.3,<3.4" install: + - travis_retry pip install -Ur requirements/travis-ci.txt - travis_retry pip install $DJANGO $REST_FRAMEWORK - - travis_retry pip install -r requirements/travis-ci.txt script: - - coverage run --source django_filters runtests.py -v 2 + - $W coverage run --source django_filters runtests.py -v 2 - coverage report notifications: @@ -36,6 +36,10 @@ matrix: env: DJANGO="django>=1.10.0,<1.11.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" - python: "3.3" env: DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + include: + - python: "3.5" + env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' allow_failures: + - env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' fast_finish: true diff --git a/requirements/maintainer.txt b/requirements/maintainer.txt index f7cdb98..63f5935 100644 --- a/requirements/maintainer.txt +++ b/requirements/maintainer.txt @@ -13,13 +13,14 @@ pathtools==0.1.2 pbr==1.7.0 pkginfo==1.2.1 Pygments==2.1.3 -pytz==2015.7 +pytz==2016.6.1 PyYAML==3.11 requests==2.9.1 requests-toolbelt==0.6.0 six==1.9.0 snowballstemmer==1.2.1 Sphinx==1.3.6 +sphinx-autobuild==0.6.0 sphinx-rtd-theme==0.1.9 tornado==4.2.1 twine==1.6.5 diff --git a/requirements/test.txt b/requirements/test.txt index 787570b..5b885fa 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,6 +1,3 @@ --r maintainer.txt -coverage==3.7.1 -django-discover-runner -mock -sphinx-autobuild +-r travis-ci.txt +django djangorestframework diff --git a/requirements/travis-ci.txt b/requirements/travis-ci.txt index a43a1b7..85ceb08 100644 --- a/requirements/travis-ci.txt +++ b/requirements/travis-ci.txt @@ -1 +1,3 @@ --r test.txt +coverage +mock +pytz From 69f6cf812e327ac4b789bec2f9a9f4abfccd9526 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Tue, 20 Sep 2016 15:15:15 -0400 Subject: [PATCH 42/45] Fix reqs for tox, add DRF to envs --- .travis.yml | 2 +- requirements/{travis-ci.txt => test-ci.txt} | 0 tox.ini | 10 ++++++---- 3 files changed, 7 insertions(+), 5 deletions(-) rename requirements/{travis-ci.txt => test-ci.txt} (100%) diff --git a/.travis.yml b/.travis.yml index d49d8fa..5446ee6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ env: - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.3,<3.4" install: - - travis_retry pip install -Ur requirements/travis-ci.txt + - travis_retry pip install -Ur requirements/test-ci.txt - travis_retry pip install $DJANGO $REST_FRAMEWORK script: diff --git a/requirements/travis-ci.txt b/requirements/test-ci.txt similarity index 100% rename from requirements/travis-ci.txt rename to requirements/test-ci.txt diff --git a/tox.ini b/tox.ini index 0960508..957d43e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - {py27,py33,py34,py35}-django18, - {py27,py34,py35}-django{19,110}, - {py27,py34,py35}-django-latest + {py27,py33,py34,py35}-django18-restframework{33,34}, + {py27,py34,py35}-django{19,110}-restframework34, + {py27,py34,py35}-django-latest-restframework34 [testenv] @@ -21,4 +21,6 @@ deps = django19: django>=1.9.0,<1.10.0 django110: django>=1.10.0,<1.11.0 django-latest: https://github.com/django/django/archive/master.tar.gz - -rrequirements/test.txt + restframework33: djangorestframework>=3.3,<3.4 + restframework34: djangorestframework>=3.4,<3.5 + -rrequirements/test-ci.txt From 7b904d082e639fe5e3ea7b2242ecd859ca91493a Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 20 Sep 2016 21:53:58 +0200 Subject: [PATCH 43/45] Changes for 0.15 --- CHANGES.rst | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7bef6ba..a526669 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,34 @@ +Version 0.15.0 (2016-09-20) +--------------------------- + +This is a preparatory release for a 1.0. Lots of clean-up, lots of changes, +mostly backwards compatible. + +Most changes should raise a Deprecation Warning. + +**Note**: if you're doing *Clever Things™* with the various filter options +— ``filter_overrides`` etc — you may run into an `AttributeError` since these +are now defined on the metaclass and not on the filter itself. +(See the discussion on #459) + +Summary: Highly Recommended, but take a moment to ensure everything still works. + +* Added the DRF backend. #481 + +* Deprecated `MethodFilter` in favour of `Filter.method` #382 + +* Move filter options to metaclass #459 + +* Added `get_filter_predicate` hook. (Allows e.g. filtering on annotated fields) #469 + +* Rework Ordering options into a filter #472 + +* Hardened all deprecations for 1.0. Please do see the `Migration Notes`__ + +__ https://github.com/carltongibson/django-filter/blob/develop/docs/migration.txt + + + Version 0.14.0 (2016-08-14) --------------------------- @@ -15,6 +46,7 @@ Version 0.14.0 (2016-08-14) * Various Minor Clean up issues. + Version 0.13.0 (2016-03-11) --------------------------- From 3b9a30b113ad7d7eb192735714239d6eb4b714f3 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 20 Sep 2016 21:56:08 +0200 Subject: [PATCH 44/45] Add thanks to @rpkilby --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index a526669..4781f26 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,8 @@ Version 0.15.0 (2016-09-20) This is a preparatory release for a 1.0. Lots of clean-up, lots of changes, mostly backwards compatible. +Special thanks to Ryan P Kilby (@rpkilby) for lots of hard work. + Most changes should raise a Deprecation Warning. **Note**: if you're doing *Clever Things™* with the various filter options From 97f11fd7abf354166fd67fc7e3743e45c5aad0a8 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 20 Sep 2016 21:57:04 +0200 Subject: [PATCH 45/45] Bump version from 0.15 release --- .bumpversion.cfg | 2 +- django_filters/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 06a2bb1..dd3c97b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.0 +current_version = 0.15.0 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/django_filters/__init__.py b/django_filters/__init__.py index 6083b20..d86c9c6 100644 --- a/django_filters/__init__.py +++ b/django_filters/__init__.py @@ -3,7 +3,7 @@ from __future__ import absolute_import from .filterset import FilterSet from .filters import * -__version__ = '0.14.0' +__version__ = '0.15.0' def parse_version(version): diff --git a/docs/conf.py b/docs/conf.py index 2b8e198..d795de3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.' # built documents. # # The short X.Y version. -version = '0.14.0' +version = '0.15.0' # The full version, including alpha/beta/rc tags. -release = '0.14.0' +release = '0.15.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index b32ed5c..ab93370 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ f = open('README.rst') readme = f.read() f.close() -version = '0.14.0' +version = '0.15.0' if sys.argv[-1] == 'publish': if os.system("pip freeze | grep wheel"):