From 4998baeae791a0558645013944273794028ec7b3 Mon Sep 17 00:00:00 2001 From: Nicolas Delaby Date: Mon, 14 Mar 2016 17:52:06 +0100 Subject: [PATCH 01/49] Make UUIDFilter importable from django_filters module like others --- 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 cb08c47..a02d5bf 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -25,7 +25,7 @@ __all__ = [ 'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter', 'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter', 'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter', - 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter' + 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter', 'UUIDFilter', ] From 4943ed472d4b7c16b100169a8e74f18b71a42906 Mon Sep 17 00:00:00 2001 From: byrman Date: Mon, 14 Mar 2016 20:45:16 +0100 Subject: [PATCH 02/49] Add support for DurationField. --- CHANGES.rst | 6 +++ django_filters/filters.py | 5 ++ django_filters/filterset.py | 6 ++- docs/ref/filters.txt | 9 ++++ tests/models.py | 10 ++++ tests/test_filtering.py | 96 +++++++++++++++++++++++++++++++++++++ tests/test_filters.py | 9 ++++ tests/test_filterset.py | 1 + 8 files changed, 141 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ed3eb04..b6c61c8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +Version 0.14.0 (unreleased) +--------------------------- + +* Add support for filtering on DurationField (new in Django 1.8). + + Version 0.13.0 (2016-03-11) --------------------------- diff --git a/django_filters/filters.py b/django_filters/filters.py index a02d5bf..1198081 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -26,6 +26,7 @@ __all__ = [ 'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter', 'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter', 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter', 'UUIDFilter', + 'DurationFilter', ] @@ -247,6 +248,10 @@ class TimeFilter(Filter): field_class = forms.TimeField +class DurationFilter(Filter): + field_class = forms.DurationField + + class ModelChoiceFilter(Filter): field_class = forms.ModelChoiceField diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 5178dd0..da5a3a5 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -18,7 +18,8 @@ from django.utils.translation import ugettext as _ from .compat import remote_field, remote_model from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, - ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) + ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, + DurationFilter) from .utils import try_dbfield, get_model_field, resolve_field @@ -195,6 +196,9 @@ FILTER_FOR_DBFIELD_DEFAULTS = { models.TimeField: { 'filter_class': TimeFilter }, + models.DurationField: { + 'filter_class': DurationFilter + }, models.OneToOneField: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 4cd2186..3af5147 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -245,6 +245,15 @@ Example. :: fields = ['published'] +``DurationFilter`` +~~~~~~~~~~~~~~~~~~ + +Matches on a duration. Used with ``DurationField`` by default. + +Supports both Django ('%d %H:%M:%S.%f') and ISO 8601 formatted durations (but +only the sections that are accepted by Python's timedelta, so no year, month, +and week designators, e.g. 'P3DT10H22M'). + ``ModelChoiceFilter`` ~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/models.py b/tests/models.py index 37211b0..d6d1c88 100644 --- a/tests/models.py +++ b/tests/models.py @@ -182,3 +182,13 @@ class Business(models.Model): class UUIDTestModel(models.Model): uuid = models.UUIDField() + + +class SpacewalkRecord(models.Model): + """Cumulative space walk record. + + See: https://en.wikipedia.org/wiki/List_of_cumulative_spacewalk_records + + """ + astronaut = models.CharField(max_length=100) + duration = models.DurationField() diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 79c026e..01c7aed 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -20,6 +20,7 @@ from django_filters.filters import DateRangeFilter 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 @@ -43,6 +44,7 @@ from .models import Profile from .models import Node from .models import DirectedNode from .models import STATUS_CHOICES +from .models import SpacewalkRecord class CharFilterTests(TestCase): @@ -300,6 +302,100 @@ class DateTimeFilterTests(TestCase): self.assertQuerysetEqual(f.qs, [2], lambda o: o.pk) +class DurationFilterTests(TestCase): + """Duration filter tests. + + The preferred format for durations in Django is '%d %H:%M:%S.%f'. + See django.utils.dateparse.parse_duration + + Django is not fully ISO 8601 compliant (yet): year, month, and + week designators are not supported, so a duration string + like "P3Y6M4DT12H30M5S" cannot be used. + See https://en.wikipedia.org/wiki/ISO_8601#Durations + + """ + def setUp(self): + self.r1 = SpacewalkRecord.objects.create( + astronaut="Anatoly Solovyev", + duration=datetime.timedelta(hours=82, minutes=22)) + self.r2 = SpacewalkRecord.objects.create( + astronaut="Michael Lopez-Alegria", + duration=datetime.timedelta(hours=67, minutes=40)) + self.r3 = SpacewalkRecord.objects.create( + astronaut="Jerry L. Ross", + duration=datetime.timedelta(hours=58, minutes=32)) + self.r4 = SpacewalkRecord.objects.create( + astronaut="John M. Grunsfeld", + duration=datetime.timedelta(hours=58, minutes=30)) + self.r5 = SpacewalkRecord.objects.create( + astronaut="Richard Mastracchio", + duration=datetime.timedelta(hours=53, minutes=4)) + + def test_filtering(self): + + class F(FilterSet): + class Meta: + model = SpacewalkRecord + fields = ['duration'] + + qs = SpacewalkRecord.objects.all() + + # Django style: 3 days, 10 hours, 22 minutes. + f = F({'duration': '3 10:22:00'}, queryset=qs) + self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x) + + # ISO 8601: 3 days, 10 hours, 22 minutes. + f = F({'duration': 'P3DT10H22M'}, queryset=qs) + self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x) + + # Django style: 82 hours, 22 minutes. + f = F({'duration': '82:22:00'}, queryset=qs) + self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x) + + # ISO 8601: 82 hours, 22 minutes. + f = F({'duration': 'PT82H22M'}, queryset=qs) + self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x) + + def test_filtering_with_single_lookup_expr_dictionary(self): + + class F(FilterSet): + class Meta: + model = SpacewalkRecord + fields = {'duration': ['gt', 'gte', 'lt', 'lte']} + + qs = SpacewalkRecord.objects.order_by('-duration') + + f = F({'duration__gt': 'PT58H30M'}, queryset=qs) + self.assertQuerysetEqual( + f.qs, [self.r1, self.r2, self.r3], lambda x: x) + + f = F({'duration__gte': 'PT58H30M'}, queryset=qs) + self.assertQuerysetEqual( + f.qs, [self.r1, self.r2, self.r3, self.r4], lambda x: x) + + f = F({'duration__lt': 'PT58H30M'}, queryset=qs) + self.assertQuerysetEqual( + f.qs, [self.r5], lambda x: x) + + f = F({'duration__lte': 'PT58H30M'}, queryset=qs) + self.assertQuerysetEqual( + f.qs, [self.r4, self.r5], lambda x: x) + + def test_filtering_with_multiple_lookup_exprs(self): + + class F(FilterSet): + min_duration = DurationFilter(name='duration', lookup_expr='gte') + max_duration = DurationFilter(name='duration', lookup_expr='lte') + + class Meta: + model = SpacewalkRecord + + qs = SpacewalkRecord.objects.order_by('duration') + + f = F({'min_duration': 'PT55H', 'max_duration': 'PT60H'}, queryset=qs) + self.assertQuerysetEqual(f.qs, [self.r4, self.r3], lambda x: x) + + class ModelChoiceFilterTests(TestCase): def test_filtering(self): diff --git a/tests/test_filters.py b/tests/test_filters.py index 6b2db80..bcf799d 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -27,6 +27,7 @@ from django_filters.filters import ( DateFilter, DateTimeFilter, TimeFilter, + DurationFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, @@ -475,6 +476,14 @@ class TimeFilterTests(TestCase): self.assertIsInstance(field, forms.TimeField) +class DurationFilterTests(TestCase): + + def test_default_field(self): + f = DurationFilter() + field = f.field + self.assertIsInstance(field, forms.DurationField) + + class ModelChoiceFilterTests(TestCase): def test_default_field_without_queryset(self): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 8876075..2519de6 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -86,6 +86,7 @@ class DbFieldDefaultFiltersTests(TestCase): models.SmallIntegerField, models.TextField, models.TimeField, + models.DurationField, models.URLField, models.ForeignKey, models.OneToOneField, From 181eb6277b470a129eff4377575e84e94cf8a109 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 14:41:22 -0400 Subject: [PATCH 03/49] Fix #320 --- tests/test_filters.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index bcf799d..ce9b7a2 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -283,7 +283,6 @@ class BooleanFilterTests(TestCase): qs.exclude.assert_called_once_with(somefield__exact=True) self.assertNotEqual(qs, result) - @unittest.expectedFailure def test_filtering_skipped_with_blank_value(self): qs = mock.Mock() f = BooleanFilter(name='somefield') @@ -380,7 +379,6 @@ class MultipleChoiceFilterTests(TestCase): result = f.filter(qs, ['other', 'values', 'there']) self.assertEqual(qs, result) - @unittest.expectedFailure def test_filtering_skipped_with_empty_list_value_and_some_choices(self): qs = mock.Mock(spec=[]) f = MultipleChoiceFilter(name='somefield') From d12bf5aee4fbfc4fa48144d97414a7105dee04b7 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 15:54:57 -0400 Subject: [PATCH 04/49] Fix docs link for NumericRangeFilter --- docs/ref/filters.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 3af5147..2c17887 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -299,7 +299,7 @@ the Postgres Numerical Range Fields, including `IntegerRangeField`, `BigIntegerR available since Django 1.8. The default widget used is the `RangeField`. RangeField lookup_exprs can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be -found in the Django docs ([https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields](https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields)). +found in the Django docs (https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields). If the lower limit value is provided, the filter automatically defaults to `__startswith` as the lookup type and if only the upper limit value is provided, the filter uses `__endswith`. From a071827190ab21600290a763baac99981e1c53fa Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 15:56:34 -0400 Subject: [PATCH 05/49] Remove expected failure for NumericRangeFilter - Other 'range' filters do not automatically switch ordering. - This may not be the appropriate behavior anyway, since not all lookups may expect to be ordered. --- tests/test_filters.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index ce9b7a2..312ec57 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -574,14 +574,6 @@ class NumericRangeFilterTests(TestCase): f.filter(qs, value) qs.filter.assert_called_once_with(None__overlap=(20, 30)) - @unittest.expectedFailure - def test_filtering_lower_field_higher_than_upper_field(self): - qs = mock.Mock(spec=['filter']) - value = mock.Mock(start=35, stop=30) - f = NumericRangeFilter() - result = f.filter(qs, value) - self.assertEqual(qs, result) - def test_zero_to_zero(self): qs = mock.Mock(spec=['filter']) value = mock.Mock(start=0, stop=0) From dca832bedb29a817854735c2a43a6fe626ce6dba Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 16:21:35 -0400 Subject: [PATCH 06/49] Ignore parent_links in MTI setups --- django_filters/filterset.py | 7 +++++-- tests/test_filterset.py | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index da5a3a5..90804d0 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -63,8 +63,11 @@ def filters_for_model(model, fields=None, exclude=None, filter_for_field=None, field_dict = OrderedDict() opts = model._meta if fields is None: - fields = [f.name for f in sorted(opts.fields + opts.many_to_many) - if not isinstance(f, models.AutoField)] + fields = [ + f.name for f in sorted(opts.fields + opts.many_to_many) + if not isinstance(f, models.AutoField) and + not (getattr(remote_field(f), 'parent_link', False)) + ] # Loop through the list of fields. for f in fields: # Skip the field if excluded. diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 2519de6..e3dc5dd 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -482,7 +482,6 @@ class FilterSetClassCreationTests(TestCase): self.assertEqual(list(F.base_filters), list(ProxyF.base_filters)) - @unittest.expectedFailure def test_filterset_for_mti_model(self): class F(FilterSet): class Meta: From 04c3ab33b2c546ecd2eeed704cb1baba343dbb3b Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 16:36:10 -0400 Subject: [PATCH 07/49] Fix DateRangeFilter default tests test_filtering_skipped_with_blank_value was contradictory to test_filtering. Removed the test but updated the behavior to noop on an empty value. --- django_filters/filters.py | 2 +- tests/test_filters.py | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 1198081..13d0d37 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -302,7 +302,7 @@ def _truncate(dt): class DateRangeFilter(ChoiceFilter): options = { - '': (_('Any date'), lambda qs, name: qs.all()), + '': (_('Any date'), lambda qs, name: qs), 1: (_('Today'), lambda qs, name: qs.filter(**{ '%s__year' % name: now().year, '%s__month' % name: now().month, diff --git a/tests/test_filters.py b/tests/test_filters.py index 312ec57..d9baf36 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -644,14 +644,7 @@ class DateRangeFilterTests(TestCase): self.assertIsInstance(field, forms.ChoiceField) def test_filtering(self): - qs = mock.Mock(spec=['all']) - f = DateRangeFilter() - f.filter(qs, '') - qs.all.assert_called_once_with() - - # the correct behavior fails right now - @unittest.expectedFailure - def test_filtering_skipped_with_blank_value(self): + # skip filtering, as it's an empty value qs = mock.Mock(spec=[]) f = DateRangeFilter() result = f.filter(qs, '') From b6b2b6a37c9f4d26b91ac197eb37175b883a6cd0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 16:45:56 -0400 Subject: [PATCH 08/49] Raise assertion on unexpected DateRangeFilter val `ChoiceFilter`'s field validation would prevent an invalid option from being selected, making a KeyError impossible. --- django_filters/filters.py | 2 ++ tests/test_filters.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 13d0d37..979e350 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -336,6 +336,8 @@ class DateRangeFilter(ChoiceFilter): value = int(value) except (ValueError, TypeError): value = '' + + assert value in self.options qs = self.options[value][1](qs, self.name) if self.distinct: qs = qs.distinct() diff --git a/tests/test_filters.py b/tests/test_filters.py index d9baf36..afc068d 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -650,12 +650,12 @@ class DateRangeFilterTests(TestCase): result = f.filter(qs, '') self.assertEqual(qs, result) - @unittest.expectedFailure def test_filtering_skipped_with_out_of_range_value(self): + # Field validation should prevent this from occuring qs = mock.Mock(spec=[]) f = DateRangeFilter() - result = f.filter(qs, 999) - self.assertEqual(qs, result) + with self.assertRaises(AssertionError): + f.filter(qs, 999) def test_filtering_for_this_year(self): qs = mock.Mock(spec=['filter']) From 0284552a722e5d88a69a040441f34266594f03f6 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 17:02:38 -0400 Subject: [PATCH 09/49] Update lookup_type => lookup_expr in test --- tests/test_filters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index afc068d..18092c3 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -794,11 +794,11 @@ class DateTimeFromToRangeFilterTests(TestCase): result = f.filter(qs, None) self.assertEqual(qs, result) - def test_filtering_ignores_lookup_type(self): + def test_filtering_ignores_lookup_expr(self): qs = mock.Mock() value = mock.Mock( start=datetime(2015, 4, 7, 8, 30), stop=datetime(2015, 9, 6, 11, 45)) - f = DateTimeFromToRangeFilter(lookup_type='gte') + f = DateTimeFromToRangeFilter(lookup_expr='gte') f.filter(qs, value) qs.filter.assert_called_once_with( None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45))) From d7cb02279b1ada294f2b7dd98148a5fdd416963a Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 25 Mar 2016 17:12:14 -0400 Subject: [PATCH 10/49] Fix DateRangeFilter date truncation. --- django_filters/filters.py | 2 +- tests/test_filtering.py | 3 --- tests/test_filters.py | 24 ++++++++++++------------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 979e350..201f0a0 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -297,7 +297,7 @@ class RangeFilter(Filter): def _truncate(dt): - return dt.replace(hour=0, minute=0, second=0) + return dt.date() class DateRangeFilter(ChoiceFilter): diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 01c7aed..439cdc8 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -653,8 +653,6 @@ class RangeFilterTests(TestCase): lambda o: o.title) - -@unittest.skip('date-range is funky') class DateRangeFilterTests(TestCase): def setUp(self): @@ -693,7 +691,6 @@ class DateRangeFilterTests(TestCase): f = F({'date': '3'}) # this month self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False) - @unittest.expectedFailure def test_filtering_for_week(self): class F(FilterSet): date = DateRangeFilter() diff --git a/tests/test_filters.py b/tests/test_filters.py index 18092c3..8aa0ab0 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -677,18 +677,18 @@ class DateRangeFilterTests(TestCase): def test_filtering_for_7_days(self): qs = mock.Mock(spec=['filter']) - with mock.patch('django_filters.filters.now'): - with mock.patch('django_filters.filters.timedelta') as mock_td: - with mock.patch( - 'django_filters.filters._truncate') as mock_truncate: - mock_dt1, mock_dt2 = mock.MagicMock(), mock.MagicMock() - mock_truncate.side_effect = [mock_dt1, mock_dt2] - f = DateRangeFilter() - f.filter(qs, '2') - self.assertEqual(mock_td.call_args_list, - [mock.call(days=7), mock.call(days=1)]) - qs.filter.assert_called_once_with( - None__lt=mock_dt2, None__gte=mock_dt1) + with mock.patch('django_filters.filters.now'), \ + mock.patch('django_filters.filters.timedelta') as mock_td, \ + mock.patch('django_filters.filters._truncate') as mock_truncate: + mock_d1, mock_d2 = mock.MagicMock(), mock.MagicMock() + mock_truncate.side_effect = [mock_d1, mock_d2] + f = DateRangeFilter() + f.filter(qs, '2') + self.assertEqual( + mock_td.call_args_list, + [mock.call(days=7), mock.call(days=1)] + ) + qs.filter.assert_called_once_with(None__lt=mock_d2, None__gte=mock_d1) def test_filtering_for_today(self): qs = mock.Mock(spec=['filter']) From 650a1369851e0f68f7c6a10a1015902d8bc53c9d Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Tue, 29 Mar 2016 10:57:28 -0400 Subject: [PATCH 11/49] Fix filter ref docs typos --- docs/ref/filters.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 2c17887..409a913 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -469,7 +469,7 @@ Example. :: pass class F(FilterSet): - id__in = NumberInFilter(name=id, lookup_expr='in') + id__in = NumberInFilter(name='id', lookup_expr='in') class Meta: model = User @@ -494,7 +494,7 @@ Example. :: pass class F(FilterSet): - id__range = NumberRangeFilter(name=id, lookup_expr='range') + id__range = NumberRangeFilter(name='id', lookup_expr='range') class Meta: model = User From cf045f5283c11512e192475e433d75673ea9fead Mon Sep 17 00:00:00 2001 From: v1k45 Date: Fri, 1 Apr 2016 23:07:15 +0530 Subject: [PATCH 12/49] added rangewidget to docs --- docs/ref/filters.txt | 2 ++ docs/ref/widgets.txt | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 409a913..8a7ca8e 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -40,6 +40,8 @@ additional ones that django-filter provides which may be useful: * :ref:`CSVWidget ` -- this widget expects a comma separated value and converts it into a list of string values. It is expected that the field class handle a list of values as well as type conversion. + * :ref:`RangeWidget ` -- this widget is used with ``RangeFilter`` + to generate two form input elements using a single field. ``action`` ~~~~~~~~~~ diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index 4aabf1c..d054c58 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -42,3 +42,17 @@ To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``:: This widget expects a comma separated value and converts it into a list of string values. It is expected that the field class handle a list of values as well as type conversion. + + +.. _range-widget: + +``RangeWidget`` +~~~~~~~~~~~~~~~ + +This widget is used with ``RangeFilter`` and its subclasses. It generates two +form input elements which generally act as start/end values in a range. +Under the hood, it is django's ``forms.TextInput`` widget and excepts +the same arguments and values. To use it, pass it to ``widget`` argument of +a ``RangeField``:: + + date_range = DateFromToRangeFilter(widget=RangeWidget(attrs={'placeholder': 'YYYY/MM/DD'})) From 17799dd613f535fa7b586e116315a2195b34ee0a Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 1 Apr 2016 14:35:50 -0400 Subject: [PATCH 13/49] Fix date range tests by asserting what isn't valid --- tests/test_filtering.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 439cdc8..7caf1de 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -653,6 +653,13 @@ class RangeFilterTests(TestCase): lambda o: o.title) +# TODO: +# year & month filtering could be better. The problem is that the test dates +# are relative to today, which is always changing. So, two_weeks_ago is not a +# valid date for 'this month' during the first half of the month, but is during +# the second half. Similary, five_days_ago is not during 'this year' when the +# tests are ran on January 1. All we can test is what is absolutely never valid +# eg, a date from two_years_ago is never a valid date for 'this year'. class DateRangeFilterTests(TestCase): def setUp(self): @@ -661,6 +668,7 @@ class DateRangeFilterTests(TestCase): two_weeks_ago = today - datetime.timedelta(days=14) two_months_ago = today - datetime.timedelta(days=62) two_years_ago = today - datetime.timedelta(days=800) + alex = User.objects.create(username='alex') time = now().time() Comment.objects.create(date=two_weeks_ago, author=alex, time=time) @@ -678,7 +686,10 @@ class DateRangeFilterTests(TestCase): fields = ['date'] f = F({'date': '4'}) # this year - self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False) + + # assert what is NOT valid for now. + # self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False) + self.assertNotIn(2, f.qs.values_list('pk', flat=True)) def test_filtering_for_month(self): class F(FilterSet): @@ -689,7 +700,11 @@ class DateRangeFilterTests(TestCase): fields = ['date'] f = F({'date': '3'}) # this month - self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False) + + # assert what is NOT valid for now. + # self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False) + self.assertNotIn(2, f.qs.values_list('pk', flat=True)) + self.assertNotIn(5, f.qs.values_list('pk', flat=True)) def test_filtering_for_week(self): class F(FilterSet): From 6c41e563ac9be5bd1486130b70d2446e6f55f4bf Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 1 Apr 2016 14:35:50 -0400 Subject: [PATCH 14/49] Fix date range tests by asserting what isn't valid --- tests/test_filtering.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 439cdc8..7caf1de 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -653,6 +653,13 @@ class RangeFilterTests(TestCase): lambda o: o.title) +# TODO: +# year & month filtering could be better. The problem is that the test dates +# are relative to today, which is always changing. So, two_weeks_ago is not a +# valid date for 'this month' during the first half of the month, but is during +# the second half. Similary, five_days_ago is not during 'this year' when the +# tests are ran on January 1. All we can test is what is absolutely never valid +# eg, a date from two_years_ago is never a valid date for 'this year'. class DateRangeFilterTests(TestCase): def setUp(self): @@ -661,6 +668,7 @@ class DateRangeFilterTests(TestCase): two_weeks_ago = today - datetime.timedelta(days=14) two_months_ago = today - datetime.timedelta(days=62) two_years_ago = today - datetime.timedelta(days=800) + alex = User.objects.create(username='alex') time = now().time() Comment.objects.create(date=two_weeks_ago, author=alex, time=time) @@ -678,7 +686,10 @@ class DateRangeFilterTests(TestCase): fields = ['date'] f = F({'date': '4'}) # this year - self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False) + + # assert what is NOT valid for now. + # self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False) + self.assertNotIn(2, f.qs.values_list('pk', flat=True)) def test_filtering_for_month(self): class F(FilterSet): @@ -689,7 +700,11 @@ class DateRangeFilterTests(TestCase): fields = ['date'] f = F({'date': '3'}) # this month - self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False) + + # assert what is NOT valid for now. + # self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False) + self.assertNotIn(2, f.qs.values_list('pk', flat=True)) + self.assertNotIn(5, f.qs.values_list('pk', flat=True)) def test_filtering_for_week(self): class F(FilterSet): From 814dc2e34bc329673b190b5252038bd060d57948 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 4 Apr 2016 02:44:51 -0400 Subject: [PATCH 15/49] Resolve #398 --- docs/ref/filters.txt | 11 +++++-- docs/usage.txt | 68 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 8a7ca8e..cfea168 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -460,10 +460,15 @@ database. So if in the DB for the given field you have values of 5, 7, and 9 each of those is present as an option. This is similar to the default behavior of the admin. +.. _base-in-filter: + ``BaseInFilter`` ~~~~~~~~~~~~~~~~ -This is a base class used for generating IN lookup filters. Values are expected to be comma separated. +This is a base class used for creating IN lookup filters. It is expected that +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. :: @@ -488,7 +493,9 @@ Example. :: ``BaseRangeFilter`` ~~~~~~~~~~~~~~~~~~~ -This is a base class used for generating RANGE lookup filters. The base class expects two values two, separated by a comma. +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. :: diff --git a/docs/usage.txt b/docs/usage.txt index 10609fc..257240b 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -79,7 +79,19 @@ For Django version 1.8, transformed expressions are not supported. .. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups -While both of these fields are optional, it is recommended that you specify + +Common declarative problems +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Below are some of the common problem that occur when declaring filters. It is +recommended that you do read this as it provides a more complete understanding +on how filters work. + + +Filter ``name`` and ``lookup_expr`` not configured +"""""""""""""""""""""""""""""""""""""""""""""""""" + +While ``name`` and ``lookup_expr`` are optional, it is recommended that you specify them. By default, if ``name`` is not specified, the filter's name on the filterset class will be used. Additionally, ``lookup_expr`` defaults to ``exact``. The following is an example of a misconfigured price filter:: @@ -92,14 +104,56 @@ lookup type. Under the hood, this will incorrectly be resolved as:: Produce.objects.filter(price__gt__exact=value) -.. note:: +This will mostly likely generate a ``FieldError``. The correct configuration +would be:: - It's quite common to forget to set the lookup expression for - :code:`CharField` and :code:`TextField` and wonder why a search for "foo" - does not return results for "foobar". This is because the default lookup - type is `exact`, but you probably want to perform an `icontains` lookup. + class ProductFilter(django_filters.FilterSet): + price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt') -For more information on additional arguments, see :ref:`core-arguments`. + +Missing ``lookup_expr`` for text search filters +""""""""""""""""""""""""""""""""""""""""""""""" + +It's quite common to forget to set the lookup expression for :code:`CharField` +and :code:`TextField` and wonder why a search for "foo" does not return results +for "foobar". This is because the default lookup type is ``exact``, but you +probably want to perform an ``icontains`` lookup. + + +Filter and lookup expression mismatch (in, range, isnull) +""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +It's not always appropriate to directly match a filter to its model field's +type as some lookups change the expected type of value for the query. This is a +commonly found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look +at the following product model:: + + class Product(models.Model): + category = models.ForeignKey(Category, null=True) + +Given that ``category`` is optional, it's reasonable to want to enable a search +for uncategorized products. The following is an incorrectly configured +``isnull`` filter:: + + class ProductFilter(django_filters.FilterSet): + uncategorized = django_filters.NumberFilter(name='category', lookup_expr='isnull') + +So what's the issue? While the underlying column type for ``category`` is an +integer, ``isnull`` lookups expect a boolean value. A ``NumberFilter`` however +only validates numbers. Filters are not `'expression aware'` and won't change +behavior based on their ``lookup_expr``. You should use filters that match the +data type of the lookup expression `instead` of the data type underlying model +field. The following would correctly allow you to search for both uncategorized +products and products for a set of categories:: + + class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): + pass + + class ProductFilter(django_filters.FilterSet): + categories = NumberInFilter(name='category', lookup_expr='in') + uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull') + +More info on constructing IN :ref:`filters `. Generating filters with Meta.fields From efdc89ac8c3d82623ba912688d5411c81d1a1abb Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 4 Apr 2016 02:45:17 -0400 Subject: [PATCH 16/49] Add sphinx theme to local builds --- docs/conf.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index e6d2650..1db554f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -240,3 +240,15 @@ texinfo_documents = [ # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' + + +# see: +# https://github.com/snide/sphinx_rtd_theme#using-this-theme-locally-then-building-on-read-the-docs +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' + +# only import and set the theme if we're building docs locally +if not on_rtd: + import sphinx_rtd_theme + + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] From 48783a8f847d7cca11f715a7ac6390d65f661925 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 4 Apr 2016 03:01:39 -0400 Subject: [PATCH 17/49] Small grammar fixes --- docs/usage.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index 257240b..0ad4ede 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -104,7 +104,7 @@ lookup type. Under the hood, this will incorrectly be resolved as:: Produce.objects.filter(price__gt__exact=value) -This will mostly likely generate a ``FieldError``. The correct configuration +The above will most likely generate a ``FieldError``. The correct configuration would be:: class ProductFilter(django_filters.FilterSet): @@ -124,8 +124,8 @@ Filter and lookup expression mismatch (in, range, isnull) """"""""""""""""""""""""""""""""""""""""""""""""""""""""" It's not always appropriate to directly match a filter to its model field's -type as some lookups change the expected type of value for the query. This is a -commonly found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look +type, as some lookups expect different types of values. This is a commonly +found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look at the following product model:: class Product(models.Model): @@ -153,7 +153,7 @@ products and products for a set of categories:: categories = NumberInFilter(name='category', lookup_expr='in') uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull') -More info on constructing IN :ref:`filters `. +More info on constructing IN and RANGE csv :ref:`filters `. Generating filters with Meta.fields From 8feac3930bda0c7ad5d782b8bda6691a380a04da Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Tue, 12 Apr 2016 12:35:54 -0400 Subject: [PATCH 18/49] Fix runshell sync behavior --- runshell.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/runshell.py b/runshell.py index 63de92e..6051442 100755 --- a/runshell.py +++ b/runshell.py @@ -1,12 +1,17 @@ #!/usr/bin/env python import os import sys +import django from django.core.management import execute_from_command_line def runshell(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") - execute_from_command_line(sys.argv[:1] + ['migrate', '--noinput', '-v', '0']) + execute_from_command_line( + sys.argv[:1] + + ['migrate', '--noinput', '-v', '0'] + + (['--run-syncdb'] if django.VERSION >= (1, 9) else [])) + argv = sys.argv[:1] + ['shell'] + sys.argv[1:] execute_from_command_line(argv) From f61d7a7324ad7ec5513c0da66349927670a82b17 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 16 Apr 2016 20:39:35 -0400 Subject: [PATCH 19/49] Add more explanatory FieldLookupError --- django_filters/exceptions.py | 9 +++++++++ django_filters/utils.py | 39 ++++++++++++++++++++---------------- tests/test_filtering.py | 14 +++++++++++++ tests/test_utils.py | 18 ++++++++++++----- 4 files changed, 58 insertions(+), 22 deletions(-) create mode 100644 django_filters/exceptions.py diff --git a/django_filters/exceptions.py b/django_filters/exceptions.py new file mode 100644 index 0000000..98b62c2 --- /dev/null +++ b/django_filters/exceptions.py @@ -0,0 +1,9 @@ + +from django.core.exceptions import FieldError + + +class FieldLookupError(FieldError): + def __init__(self, model_field, lookup_expr): + super(FieldLookupError, self).__init__( + "Unsupported lookup '%s' for field '%s'." % (lookup_expr, model_field) + ) diff --git a/django_filters/utils.py b/django_filters/utils.py index 0568a3d..a5cb3dd 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -1,12 +1,14 @@ from django.conf import settings +from django.core.exceptions import FieldError from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import Expression from django.db.models.fields import FieldDoesNotExist from django.db.models.fields.related import ForeignObjectRel -from django.utils import timezone +from django.utils import six, timezone from .compat import remote_model +from .exceptions import FieldLookupError def try_dbfield(fn, field_class): @@ -78,22 +80,25 @@ def resolve_field(model_field, lookup_expr): assert len(lookups) > 0 - while lookups: - name = lookups[0] - # If there is just one part left, try first get_lookup() so - # that if the lhs supports both transform and lookup for the - # name, then lookup will be picked. - if len(lookups) == 1: - final_lookup = lhs.get_lookup(name) - if not final_lookup: - # We didn't find a lookup. We are going to interpret - # the name as transform, and do an Exact lookup against - # it. - lhs = query.try_transform(lhs, name, lookups) - final_lookup = lhs.get_lookup('exact') - return lhs.output_field, final_lookup.lookup_name - lhs = query.try_transform(lhs, name, lookups) - lookups = lookups[1:] + try: + while lookups: + name = lookups[0] + # If there is just one part left, try first get_lookup() so + # that if the lhs supports both transform and lookup for the + # name, then lookup will be picked. + if len(lookups) == 1: + final_lookup = lhs.get_lookup(name) + if not final_lookup: + # We didn't find a lookup. We are going to interpret + # the name as transform, and do an Exact lookup against + # it. + lhs = query.try_transform(lhs, name, lookups) + final_lookup = lhs.get_lookup('exact') + return lhs.output_field, final_lookup.lookup_name + lhs = query.try_transform(lhs, name, lookups) + lookups = lookups[1:] + except FieldError as e: + six.raise_from(FieldLookupError(model_field, lookup_expr), e) def handle_timezone(value): diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 7caf1de..fd8eb65 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -28,6 +28,7 @@ from django_filters.filters import NumberFilter from django_filters.filters import RangeFilter from django_filters.filters import TimeRangeFilter # from django_filters.widgets import LinkWidget +from django_filters.exceptions import FieldLookupError from .models import User from .models import Comment @@ -1718,3 +1719,16 @@ class MiscFilterSetTests(TestCase): f = F({'status': '2'}, queryset=qs) self.assertEqual(len(f.qs), 2) self.assertEqual(f.count(), 2) + + def test_invalid_field_lookup(self): + # We want to ensure that non existent lookups (or just simple misspellings) + # throw a useful exception containg the field and lookup expr. + with self.assertRaises(FieldLookupError) as context: + class F(FilterSet): + class Meta: + model = User + fields = {'username': ['flub']} + + exc = str(context.exception) + self.assertIn('tests.User.username', exc) + self.assertIn('flub', exc) diff --git a/tests/test_utils.py b/tests/test_utils.py index 90285c5..43ed227 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,9 +5,9 @@ import django from django.test import TestCase from django.db import models from django.db.models.constants import LOOKUP_SEP -from django.core.exceptions import FieldError from django_filters.utils import get_model_field, resolve_field +from django_filters.exceptions import FieldLookupError from .models import User from .models import Article @@ -156,11 +156,19 @@ class ResolveFieldTests(TestCase): def test_invalid_lookup_expression(self): model_field = Article._meta.get_field('published') - with self.assertRaises(FieldError): - field, lookup = resolve_field(model_field, 'invalid_lookup') + with self.assertRaises(FieldLookupError) as context: + resolve_field(model_field, 'invalid_lookup') + + exc = str(context.exception) + self.assertIn(str(model_field), exc) + self.assertIn('invalid_lookup', exc) def test_invalid_transformed_lookup_expression(self): model_field = Article._meta.get_field('published') - with self.assertRaises(FieldError): - field, lookup = resolve_field(model_field, 'date__invalid_lookup') + with self.assertRaises(FieldLookupError) as context: + resolve_field(model_field, 'date__invalid_lookup') + + exc = str(context.exception) + self.assertIn(str(model_field), exc) + self.assertIn('date__invalid_lookup', exc) From aa73a270b2f8b8a49cdea86d2e1ef65162a87456 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 29 Apr 2016 19:57:22 -0400 Subject: [PATCH 20/49] Add filters_for_model as a class method --- django_filters/filterset.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 90804d0..cc1b579 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -160,9 +160,7 @@ class FilterSetMetaclass(type): opts = new_class._meta = FilterSetOptions( getattr(new_class, 'Meta', None)) if opts.model: - filters = filters_for_model(opts.model, opts.fields, opts.exclude, - new_class.filter_for_field, - new_class.filter_for_reverse_field) + filters = new_class.filters_for_model(opts.model, opts) filters.update(declared_filters) else: filters = declared_filters @@ -424,6 +422,14 @@ class BaseFilterSet(object): return [inverted + _filter.name] return [order_choice] + @classmethod + def filters_for_model(cls, model, opts): + return filters_for_model( + model, opts.fields, opts.exclude, + cls.filter_for_field, + cls.filter_for_reverse_field + ) + @classmethod def filter_for_field(cls, f, name, lookup_expr='exact'): f, lookup_type = resolve_field(f, lookup_expr) From b2a95b11651e7407720634fed54c20a1ffcfe309 Mon Sep 17 00:00:00 2001 From: Mihas Date: Wed, 13 May 2015 13:36:12 +0300 Subject: [PATCH 21/49] support of callable as limit_choices_to Test copied from @mihasK and updated --- tests/models.py | 36 +++++++++++++++++++++++++++++++++--- tests/test_forms.py | 23 ++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/tests/models.py b/tests/models.py index d6d1c88..c914a80 100644 --- a/tests/models.py +++ b/tests/models.py @@ -6,10 +6,14 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible +REGULAR = 0 +MANAGER = 1 +ADMIN = 2 + STATUS_CHOICES = ( - (0, 'Regular'), - (1, 'Manager'), - (2, 'Admin'), + (REGULAR, 'Regular'), + (MANAGER, 'Manager'), + (ADMIN, 'Admin'), ) @@ -54,6 +58,32 @@ class User(models.Model): def __str__(self): return self.username +@python_2_unicode_compatible +class UsersOfManager(models.Model): + users = models.ManyToManyField(User, + limit_choices_to={'is_active': True}, + related_name='users_of_manager') + manager = models.ForeignKey(User, + limit_choices_to=lambda: {'status': 1}, + related_name='his_users') + + def __str__(self): + return self.manager.name + '_group' + + +@python_2_unicode_compatible +class ManagerGroup(models.Model): + users = models.ManyToManyField(User, + limit_choices_to={'is_active': True}, + related_name='member_of') + manager = models.ForeignKey(User, + limit_choices_to=lambda: {'status': MANAGER}, + related_name='manager_of', + on_delete=models.CASCADE) + + def __str__(self): + return self.manager.name + ' group' + @python_2_unicode_compatible class AdminUser(User): diff --git a/tests/test_forms.py b/tests/test_forms.py index 684b74d..3a6d0ef 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -8,9 +8,9 @@ from django_filters.filterset import FilterSet from django_filters.filters import CharFilter from django_filters.filters import ChoiceFilter -from .models import User +from .models import User, ManagerGroup from .models import Book -from .models import STATUS_CHOICES +from .models import STATUS_CHOICES, REGULAR, MANAGER class FilterSetFormTests(TestCase): @@ -229,7 +229,7 @@ class FilterSetFormTests(TestCase): 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 queryset and ensure that the @@ -272,3 +272,20 @@ class FilterSetFormTests(TestCase): 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) + User.objects.create(username='manager', is_active=False, status=MANAGER) + + class F(FilterSet): + class Meta: + model = ManagerGroup + fields = ['users', 'manager'] + f = F().form + self.assertEquals( + list(f.fields['users'].choices), [(2, 'active')] + ) + self.assertEquals( + list(f.fields['manager'].choices), [('', '---------'), (3, 'manager')] + ) From 9d032f517d0a4ade2c7fb0446912d1e44f721d5f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 6 May 2016 21:27:07 -0400 Subject: [PATCH 22/49] Add remote_queryset, fix #413 and fix #238 --- django_filters/compat.py | 7 +++++++ django_filters/filterset.py | 13 +++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/django_filters/compat.py b/django_filters/compat.py index 41e76ec..296551d 100644 --- a/django_filters/compat.py +++ b/django_filters/compat.py @@ -15,3 +15,10 @@ def remote_model(field): if django.VERSION >= (1, 9): return remote_field(field).model return remote_field(field).to + + +def remote_queryset(field): + model = remote_model(field) + limit_choices_to = field.get_limit_choices_to() + + return model._default_manager.complex_filter(limit_choices_to) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index cc1b579..5ce3894 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 +from .compat import remote_field, remote_model, remote_queryset from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, @@ -203,24 +203,21 @@ FILTER_FOR_DBFIELD_DEFAULTS = { models.OneToOneField: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { - 'queryset': remote_model(f)._default_manager.complex_filter( - remote_field(f).limit_choices_to), + 'queryset': remote_queryset(f), 'to_field_name': remote_field(f).field_name, } }, models.ForeignKey: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { - 'queryset': remote_model(f)._default_manager.complex_filter( - remote_field(f).limit_choices_to), - 'to_field_name': remote_field(f).field_name + 'queryset': remote_queryset(f), + 'to_field_name': remote_field(f).field_name, } }, models.ManyToManyField: { 'filter_class': ModelMultipleChoiceFilter, 'extra': lambda f: { - 'queryset': remote_model(f)._default_manager.complex_filter( - remote_field(f).limit_choices_to), + 'queryset': remote_queryset(f), } }, models.DecimalField: { From f52ebfe45e11e84e9f3fa873b98ad995a0f4b20b Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Tue, 10 May 2016 16:50:14 +0100 Subject: [PATCH 23/49] Updated urlpatterns examples in docs for Django 1.8+ --- docs/usage.txt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index 0ad4ede..06af307 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -358,13 +358,13 @@ You must provide either a ``model`` or ``filterset_class`` argument, similar to ``ListView`` in Django itself:: # urls.py - from django.conf.urls import patterns, url + from django.conf.urls import url from django_filters.views import FilterView from myapp.models import Product - urlpatterns = patterns('', - (r'^list/$', FilterView.as_view(model=Product)), - ) + urlpatterns = [ + url(r'^list/$', FilterView.as_view(model=Product)), + ] You must provide a template at ``/_filter.html`` which gets the context parameter ``filter``. Additionally, the context will contain @@ -376,12 +376,13 @@ its use is deprecated. It can be found at to it as the class based view:: # urls.py - from django.conf.urls import patterns, url + from django.conf.urls import url + from django_filters.views import object_filter from myapp.models import Product - urlpatterns = patterns('', - (r'^list/$', 'django_filters.views.object_filter', {'model': Product}), - ) + urlpatterns = [ + url(r'^list/$', object_filter, {'model': Product}), + ] The needed template and its context variables will also be the same as the class-based view above. From b3b9c971df703a5b302ce4dbec0bdb2dc54cd900 Mon Sep 17 00:00:00 2001 From: Jonathan Lau Date: Wed, 11 May 2016 13:58:04 -0700 Subject: [PATCH 24/49] adding the support for distinct to the range query --- django_filters/filters.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/django_filters/filters.py b/django_filters/filters.py index 201f0a0..22ea8ef 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -277,6 +277,8 @@ class NumericRangeFilter(Filter): qs = self.get_method(qs)(**{'%s__startswith' % self.name: value.start}) if value.stop is not None: qs = self.get_method(qs)(**{'%s__endswith' % self.name: value.stop}) + if self.distinct: + qs = qs.distinct() return qs @@ -293,6 +295,8 @@ class RangeFilter(Filter): qs = self.get_method(qs)(**{'%s__gte' % self.name: value.start}) if value.stop is not None: qs = self.get_method(qs)(**{'%s__lte' % self.name: value.stop}) + if self.distinct: + qs = qs.distinct() return qs From e65ad108a72d751e5d53d80c81e4bc895813167a Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Thu, 12 May 2016 22:58:30 +0100 Subject: [PATCH 25/49] Updated readthedocs url --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 19ec278..27edf7e 100644 --- a/README.rst +++ b/README.rst @@ -69,5 +69,5 @@ Support If you have questions about usage or development you can join the `mailing list`_. -.. _`read the docs`: https://django-filter.readthedocs.org/en/latest/ +.. _`read the docs`: https://django-filter.readthedocs.io/en/latest/ .. _`mailing list`: http://groups.google.com/group/django-filter From 4c5872c13873ab838b494aadafeecb0b371e4d8e Mon Sep 17 00:00:00 2001 From: cw0102 Date: Sun, 22 May 2016 04:50:53 -0400 Subject: [PATCH 26/49] AllValuesMultipleFilter filter type Add AllValuesMultipleFilter filter type for selecting multiple of only data that already exists in the database --- django_filters/filters.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/django_filters/filters.py b/django_filters/filters.py index 22ea8ef..68e1a98 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -368,6 +368,13 @@ class AllValuesFilter(ChoiceFilter): self.extra['choices'] = [(o, o) for o in qs] return super(AllValuesFilter, self).field +class AllValuesMultipleFilter(MultipleChoiceFilter): + @property + def field(self): + qs = self.model._default_manager.distinct() + qs = qs.order_by(self.name).values_list(self.name, flat=True) + self.extra['choices'] = [(o, o) for o in qs] + return super(AllValuesMultipleFilter, self).field class BaseCSVFilter(Filter): """ From 0f58ec564c5f3cf552cfe4efd26343f573c27c8c Mon Sep 17 00:00:00 2001 From: cw0102 Date: Sun, 22 May 2016 04:53:20 -0400 Subject: [PATCH 27/49] Doc update for AllValuesMultipleFilter --- docs/ref/filters.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index cfea168..603cb07 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -460,6 +460,14 @@ database. So if in the DB for the given field you have values of 5, 7, and 9 each of those is present as an option. This is similar to the default behavior of the admin. +``AllValuesMultipleFilter`` +~~~~~~~~~~~~~~~~~~~ + +This is a ``MultipleChoiceFilter`` whose choices are the current values in the +database. So if in the DB for the given field you have values of 5, 7, and 9 +each of those is present as an option. This is similar to the default behavior +of the admin. + .. _base-in-filter: ``BaseInFilter`` From 7a9954a95e65bb72aa33423d11fa2cbec8d3c1ef Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Thu, 9 Jun 2016 08:49:28 +0200 Subject: [PATCH 28/49] Add Django to requirements/test.txt Closes #427 --- requirements/test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/test.txt b/requirements/test.txt index c41a692..9055c2f 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,3 +2,4 @@ coverage==3.7.1 django-discover-runner mock sphinx-autobuild +Django==1.9.7 From 90de20766ea149431b7ba155e8f869fa48e1c962 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Tue, 21 Jun 2016 11:15:32 +0200 Subject: [PATCH 29/49] Add UUID it filters.__all__ and alphabetise --- django_filters/filters.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 22ea8ef..ab9c0be 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -20,13 +20,28 @@ from .fields import ( __all__ = [ - 'Filter', 'CharFilter', 'BooleanFilter', 'ChoiceFilter', - 'TypedChoiceFilter', 'MultipleChoiceFilter', 'DateFilter', - 'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter', - 'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter', - 'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter', - 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter', 'UUIDFilter', - 'DurationFilter', + 'AllValuesFilter', + 'BooleanFilter', + 'CharFilter', + 'ChoiceFilter', + 'DateFilter', + 'DateFromToRangeFilter', + 'DateRangeFilter', + 'DateTimeFilter', + 'DateTimeFromToRangeFilter', + 'Filter', + 'IsoDateTimeFilter', + 'MethodFilter', + 'ModelChoiceFilter', + 'ModelMultipleChoiceFilter', + 'MultipleChoiceFilter', + 'NumberFilter', + 'NumericRangeFilter', + 'RangeFilter', + 'TimeFilter', + 'TimeRangeFilter', + 'TypedChoiceFilter', + 'UUIDFilter', ] From 248b0a7edc61fffed8a3854eef418e837f5b6705 Mon Sep 17 00:00:00 2001 From: Villiers Strauss Date: Tue, 21 Jun 2016 17:08:59 +0200 Subject: [PATCH 30/49] include DurationFilter in __all__ --- django_filters/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/django_filters/filters.py b/django_filters/filters.py index ab9c0be..ebbae65 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -29,6 +29,7 @@ __all__ = [ 'DateRangeFilter', 'DateTimeFilter', 'DateTimeFromToRangeFilter', + 'DurationFilter', 'Filter', 'IsoDateTimeFilter', 'MethodFilter', From bb6f83fcbc9a83b63157e60cd6aa35edaa5c0369 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 30 Jun 2016 01:17:48 -0400 Subject: [PATCH 31/49] Fix #439, Deprecate container-like methods --- django_filters/filterset.py | 4 ++++ tests/test_deprecations.py | 41 +++++++++++++++++++++++++++++++++++++ tests/test_filtering.py | 28 ++++++++++++------------- tests/test_filterset.py | 18 +++++++++++++++- 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/test_deprecations.py diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 5ce3894..be8cb52 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import copy import re +import warnings from collections import OrderedDict from django import forms @@ -291,13 +292,16 @@ class BaseFilterSet(object): filter_.parent = self def __iter__(self): + warnings.warn('FilterSet no longer emulates a container. Iterate over .qs instead.', DeprecationWarning, stacklevel=2) for obj in self.qs: yield obj def __len__(self): + warnings.warn('FilterSet no longer emulates a container. Get the length of .qs instead.', DeprecationWarning, stacklevel=2) return self.qs.count() def __getitem__(self, key): + warnings.warn('FilterSet no longer emulates a container. Perform lookups on .qs instead.', DeprecationWarning, stacklevel=2) return self.qs[key] @property diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py new file mode 100644 index 0000000..05a99f6 --- /dev/null +++ b/tests/test_deprecations.py @@ -0,0 +1,41 @@ + +import warnings +from django.test import TestCase + +from django_filters import FilterSet +from .models import User + + +class UserFilter(FilterSet): + class Meta: + model = User + + +class FilterSetContainerDeprecationTests(TestCase): + + def test__iter__notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + [obj for obj in UserFilter()] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test__getitem__notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + with self.assertRaises(IndexError): + UserFilter()[0] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test__len__notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + len(UserFilter()) + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index fd8eb65..350917e 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -844,9 +844,9 @@ class AllValuesFilterTests(TestCase): fields = ['username'] self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'alex'})), + self.assertEqual(list(F({'username': 'alex'}).qs), [User.objects.get(username='alex')]) - self.assertEqual(list(F({'username': 'jose'})), + self.assertEqual(list(F({'username': 'jose'}).qs), list()) def test_filtering_without_strict(self): @@ -863,9 +863,9 @@ class AllValuesFilterTests(TestCase): fields = ['username'] self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'alex'})), + self.assertEqual(list(F({'username': 'alex'}).qs), [User.objects.get(username='alex')]) - self.assertEqual(list(F({'username': 'jose'})), + self.assertEqual(list(F({'username': 'jose'}).qs), list(User.objects.all())) @@ -889,9 +889,9 @@ class MethodFilterTests(TestCase): ) self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'alex'})), + self.assertEqual(list(F({'username': 'alex'}).qs), [User.objects.get(username='alex')]) - self.assertEqual(list(F({'username': 'jose'})), + self.assertEqual(list(F({'username': 'jose'}).qs), list()) def test_filtering_external(self): @@ -912,9 +912,9 @@ class MethodFilterTests(TestCase): fields = ['username'] self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'alex'})), + self.assertEqual(list(F({'username': 'alex'}).qs), [User.objects.get(username='alex')]) - self.assertEqual(list(F({'username': 'jose'})), + self.assertEqual(list(F({'username': 'jose'}).qs), list()) @@ -936,13 +936,13 @@ class MethodFilterTests(TestCase): ) self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'})), + self.assertEqual(list(F({'username': 'mike'}).qs), [User.objects.get(username='mike'), User.objects.get(username='jake')],) - self.assertEqual(list(F({'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'})), + self.assertEqual(list(F({'username': 'aaron'}).qs), [User.objects.get(username='mike'), User.objects.get(username='jake')]) @@ -961,11 +961,11 @@ class MethodFilterTests(TestCase): fields = ['username'] self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'})), + self.assertEqual(list(F({'username': 'mike'}).qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'jake'})), + self.assertEqual(list(F({'username': 'jake'}).qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'aaron'})), + self.assertEqual(list(F({'username': 'aaron'}).qs), list(User.objects.all())) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index e3dc5dd..dcb0aa5 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -719,7 +719,6 @@ class FilterSetOrderingTests(TestCase): f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username) - class FilterSetTogetherTests(TestCase): def setUp(self): @@ -759,3 +758,20 @@ class FilterSetTogetherTests(TestCase): f = F({'username': 'alex', 'status': 1}, queryset=self.qs) self.assertEqual(f.qs.count(), 1) self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk) + + +@unittest.skip('remove when relevant deprecations have been completed') +class MiscFilterSetTests(TestCase): + + def test_no__getitem__(self): + # The DTL processes variable lookups by the following rules: + # https://docs.djangoproject.com/en/1.9/ref/templates/language/#variables + # A __getitem__ implementation precedes normal attribute access, and in + # the case of #58, will force the queryset to evaluate when it should + # not (eg, when rendering a blank form). + self.assertFalse(hasattr(FilterSet, '__getitem__')) + + def test_no_qs_proxying(self): + # The FilterSet should not proxy .qs methods - just access .qs directly + self.assertFalse(hasattr(FilterSet, '__len__')) + self.assertFalse(hasattr(FilterSet, '__iter__')) From 633c1bec06c003bf12520059f89944fc2fec67a9 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 30 Jun 2016 01:17:58 -0400 Subject: [PATCH 32/49] Remove old model --- tests/models.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/models.py b/tests/models.py index c914a80..64ee7b3 100644 --- a/tests/models.py +++ b/tests/models.py @@ -58,18 +58,6 @@ class User(models.Model): def __str__(self): return self.username -@python_2_unicode_compatible -class UsersOfManager(models.Model): - users = models.ManyToManyField(User, - limit_choices_to={'is_active': True}, - related_name='users_of_manager') - manager = models.ForeignKey(User, - limit_choices_to=lambda: {'status': 1}, - related_name='his_users') - - def __str__(self): - return self.manager.name + '_group' - @python_2_unicode_compatible class ManagerGroup(models.Model): From ebaf66db3bd88343efd99e4106cbf4d045b2f0f3 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 30 Jun 2016 01:57:54 -0400 Subject: [PATCH 33/49] Remove FilterSet iteration from docs --- docs/usage.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index 06af307..1abfe9c 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -339,13 +339,13 @@ And lastly we need a template:: {{ filter.form.as_p }} - {% for obj in filter %} + {% for obj in filter.qs %} {{ obj.name }} - ${{ obj.price }}
{% endfor %} {% endblock %} And that's all there is to it! The ``form`` attribute contains a normal -Django form, and when we iterate over the ``FilterSet`` we get the objects in +Django form, and when we iterate over the ``FilterSet.qs`` we get the objects in the resulting queryset. From 3a9cfaf797c8d27dbb28342efa7d869ef97ada89 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Wed, 13 Jul 2016 23:30:28 +0200 Subject: [PATCH 34/49] Tidy up requirements - see #305 --- .travis.yml | 1 - requirements/maintainer.txt | 4 ---- requirements/test.txt | 2 +- requirements/travis-ci.txt | 4 +--- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 40e0fe8..a46cbf9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,4 +38,3 @@ matrix: allow_failures: - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' fast_finish: true - diff --git a/requirements/maintainer.txt b/requirements/maintainer.txt index b9bbbbf..f7cdb98 100644 --- a/requirements/maintainer.txt +++ b/requirements/maintainer.txt @@ -4,14 +4,11 @@ Babel==2.2.0 backports.ssl-match-hostname==3.4.0.2 bumpversion==0.5.3 certifi==2015.9.6.2 -coverage==3.7.1 -django-discover-runner==1.0 docutils==0.12 funcsigs==0.4 Jinja2==2.8 livereload==2.4.0 MarkupSafe==0.23 -mock==1.3.0 pathtools==0.1.2 pbr==1.7.0 pkginfo==1.2.1 @@ -23,7 +20,6 @@ requests-toolbelt==0.6.0 six==1.9.0 snowballstemmer==1.2.1 Sphinx==1.3.6 -sphinx-autobuild==0.5.2 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 9055c2f..a5d50f1 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ +-r maintainer.txt coverage==3.7.1 django-discover-runner mock sphinx-autobuild -Django==1.9.7 diff --git a/requirements/travis-ci.txt b/requirements/travis-ci.txt index ad4d0da..a43a1b7 100644 --- a/requirements/travis-ci.txt +++ b/requirements/travis-ci.txt @@ -1,3 +1 @@ -django-discover-runner -mock -coverage==3.7.1 +-r test.txt From 9ecd47945e505356cb72990842223d1099587909 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Wed, 13 Jul 2016 23:54:26 +0200 Subject: [PATCH 35/49] Add pip cache to TravisCI --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 40e0fe8..493678a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,8 @@ language: python sudo: false +cache: pip + python: - "2.7" - "3.2" @@ -38,4 +40,3 @@ matrix: allow_failures: - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' fast_finish: true - From 01eab0c920b127a3770c1e5efdaf52547ae48afc Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 22 Jul 2016 16:13:37 -0400 Subject: [PATCH 36/49] Default ChoiceFilter to only exact lookups --- django_filters/filterset.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 5ce3894..79ed0ed 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -471,6 +471,9 @@ class BaseFilterSet(object): return None, {} # perform lookup specific checks + if lookup_type == 'exact' and f.choices: + return ChoiceFilter, {'choices': f.choices} + if lookup_type == 'isnull': data = try_dbfield(DEFAULTS.get, models.BooleanField) @@ -496,10 +499,6 @@ class BaseFilterSet(object): return ConcreteRangeFilter, params - # Default behavior - if f.choices: - return ChoiceFilter, {'choices': f.choices} - return filter_class, params @classmethod From 3e760469a58eb3010976791204c5203c2f8ce5c0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 30 Jul 2016 16:57:50 -0400 Subject: [PATCH 37/49] Add count method to qs deprecations --- django_filters/filterset.py | 7 +++-- tests/test_deprecations.py | 8 ++++++ tests/test_filtering.py | 56 ++++++++++++++++++------------------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index be8cb52..a8e5c29 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -304,6 +304,10 @@ class BaseFilterSet(object): warnings.warn('FilterSet no longer emulates a container. Perform lookups on .qs instead.', DeprecationWarning, stacklevel=2) return self.qs[key] + def count(self): + warnings.warn('FilterSet no longer emulates a container. Call .qs.count() instead.', DeprecationWarning, stacklevel=2) + return self.qs.count() + @property def qs(self): if not hasattr(self, '_qs'): @@ -359,9 +363,6 @@ class BaseFilterSet(object): return self._qs - def count(self): - return self.qs.count() - @property def form(self): if not hasattr(self, '_form'): diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 05a99f6..b879e4a 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -39,3 +39,11 @@ class FilterSetContainerDeprecationTests(TestCase): len(UserFilter()) self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test__count__notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + UserFilter().count() + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 350917e..3e28427 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -1524,27 +1524,27 @@ class CSVFilterTests(TestCase): qs = User.objects.all() f = F(queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'status__in': ''}, queryset=qs) self.assertEqual(len(f.qs), 0) - self.assertEqual(f.count(), 0) + self.assertEqual(f.qs.count(), 0) f = F({'status__in': '0'}, queryset=qs) self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) + self.assertEqual(f.qs.count(), 1) f = F({'status__in': '0,2'}, queryset=qs) self.assertEqual(len(f.qs), 3) - self.assertEqual(f.count(), 3) + self.assertEqual(f.qs.count(), 3) f = F({'status__in': '0,,1'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) f = F({'status__in': '2'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) def test_string_filtering(self): F = self.user_filter @@ -1552,27 +1552,27 @@ class CSVFilterTests(TestCase): qs = User.objects.all() f = F(queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'username__in': ''}, queryset=qs) self.assertEqual(len(f.qs), 0) - self.assertEqual(f.count(), 0) + self.assertEqual(f.qs.count(), 0) f = F({'username__in': 'alex'}, queryset=qs) self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) + self.assertEqual(f.qs.count(), 1) f = F({'username__in': 'alex,aaron'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) f = F({'username__in': 'alex,,aaron'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) f = F({'username__in': 'alex,'}, queryset=qs) self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) + self.assertEqual(f.qs.count(), 1) def test_datetime_filtering(self): F = self.article_filter @@ -1582,27 +1582,27 @@ class CSVFilterTests(TestCase): qs = Article.objects.all() f = F(queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'published__in': ''}, queryset=qs) self.assertEqual(len(f.qs), 0) - self.assertEqual(f.count(), 0) + self.assertEqual(f.qs.count(), 0) f = F({'published__in': '%s' % (after, )}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) f = F({'published__in': '%s,%s' % (after, before, )}, queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'published__in': '%s,,%s' % (after, before, )}, queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'published__in': '%s,' % (after, )}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) def test_related_filtering(self): F = self.article_filter @@ -1610,27 +1610,27 @@ class CSVFilterTests(TestCase): qs = Article.objects.all() f = F(queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'author__in': ''}, queryset=qs) self.assertEqual(len(f.qs), 0) - self.assertEqual(f.count(), 0) + self.assertEqual(f.qs.count(), 0) f = F({'author__in': '1'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) f = F({'author__in': '1,2'}, queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'author__in': '1,,2'}, queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'author__in': '1,'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) class MiscFilterSetTests(TestCase): @@ -1706,19 +1706,19 @@ class MiscFilterSetTests(TestCase): qs = User.objects.all() f = F(queryset=qs) self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(f.qs.count(), 4) f = F({'status': '0'}, queryset=qs) self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) + self.assertEqual(f.qs.count(), 1) f = F({'status': '1'}, queryset=qs) self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) + self.assertEqual(f.qs.count(), 1) f = F({'status': '2'}, queryset=qs) self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) + self.assertEqual(f.qs.count(), 2) def test_invalid_field_lookup(self): # We want to ensure that non existent lookups (or just simple misspellings) From 784af5918a7b0bd724f24697bf61d46281f8dc99 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 30 Jul 2016 18:44:46 -0400 Subject: [PATCH 38/49] Add migration docs --- django_filters/filterset.py | 14 ++++++++++---- docs/index.txt | 1 + docs/migration.txt | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 docs/migration.txt diff --git a/django_filters/filterset.py b/django_filters/filterset.py index a8e5c29..dc9c4af 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -27,6 +27,12 @@ from .utils import try_dbfield, get_model_field, resolve_field ORDER_BY_FIELD = 'o' +def deprecate(msg): + warnings.warn( + "%s See: https://django-filter.readthedocs.io/en/latest/migration.html" % msg, + DeprecationWarning, stacklevel=3) + + class STRICTNESS(object): """ Values of False & True chosen for backward compatability reasons. @@ -292,20 +298,20 @@ class BaseFilterSet(object): filter_.parent = self def __iter__(self): - warnings.warn('FilterSet no longer emulates a container. Iterate over .qs instead.', DeprecationWarning, stacklevel=2) + deprecate('QuerySet methods are no longer proxied.') for obj in self.qs: yield obj def __len__(self): - warnings.warn('FilterSet no longer emulates a container. Get the length of .qs instead.', DeprecationWarning, stacklevel=2) + deprecate('QuerySet methods are no longer proxied.') return self.qs.count() def __getitem__(self, key): - warnings.warn('FilterSet no longer emulates a container. Perform lookups on .qs instead.', DeprecationWarning, stacklevel=2) + deprecate('QuerySet methods are no longer proxied.') return self.qs[key] def count(self): - warnings.warn('FilterSet no longer emulates a container. Call .qs.count() instead.', DeprecationWarning, stacklevel=2) + deprecate('QuerySet methods are no longer proxied.') return self.qs.count() @property diff --git a/docs/index.txt b/docs/index.txt index cf8f7fd..0f00a70 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -19,4 +19,5 @@ Contents: ref/fields ref/widgets ref/settings + migration tests diff --git a/docs/migration.txt b/docs/migration.txt new file mode 100644 index 0000000..20cac53 --- /dev/null +++ b/docs/migration.txt @@ -0,0 +1,29 @@ +Migrating to 1.0 +================ + +The 1.0 release of django-filter introduces several API changes and refinements +that break forwards compatibility. Below is a list of deprecations and +instructions on how to migrate to the 1.0 release. A forwards-compatible 0.15 +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. + + +QuerySet methods are no longer proxied +-------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/440 + +The ``__iter__()``, ``__len__()``, ``__getitem__()``, ``count()`` methods are +no longer proxied from the queryset. To fix this, call the methods on the +``.qs`` property itself. + +.. code-block:: python + + f = UserFilter(request.GET, queryset=User.objects.all()) + + # 0.x + for obj in f: + ... + + # 1.0 + for obj in f.qs: + ... From 19931e4377e400e60747d75dddd9c02e18beb0cd Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 3 Aug 2016 14:18:04 +0100 Subject: [PATCH 39/49] Travis: Stop testing on Python 3.2 Since it's no longer supported as of 2016-02-20: https://docs.python.org/devguide/#status-of-python-branches --- .travis.yml | 5 ----- README.rst | 2 +- setup.py | 1 - tox.ini | 3 +-- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 493678a..7af599e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ cache: pip python: - "2.7" - - "3.2" - "3.3" - "3.4" - "3.5" @@ -29,10 +28,6 @@ notifications: matrix: exclude: - - python: "3.2" - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' - - python: "3.2" - env: DJANGO='django>=1.9.0,<1.10.0' - python: "3.3" env: DJANGO='https://github.com/django/django/archive/master.tar.gz' - python: "3.3" diff --git a/README.rst b/README.rst index 27edf7e..bad8281 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ Full documentation on `read the docs`_. Requirements ------------ -* Python 2.7, 3.2, 3.3, 3.4, 3.5 +* Python 2.7, 3.3, 3.4, 3.5 * Django 1.8, 1.9 Installation diff --git a/setup.py b/setup.py index 123ada3..4f14227 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,6 @@ setup( 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', diff --git a/tox.ini b/tox.ini index 59f6f96..5390fc5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - {py27,py32,py33,py34}-django{17,18}, + {py27,py33,py34}-django{17,18}, {py27,py34,py35}-django19, {py27,py34,py35}-django-latest @@ -8,7 +8,6 @@ envlist = [testenv] basepython = py27: python2.7 - py32: python3.2 py33: python3.3 py34: python3.4 py35: python3.5 From b18216a1972b5a7775e98fcc872e7adb56e2208a Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 3 Aug 2016 14:27:36 +0100 Subject: [PATCH 40/49] Tox: Remove Django 1.7 remnants These were left behind when Django 1.7 was removed from Travis testing. --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5390fc5..b1f3e5b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - {py27,py33,py34}-django{17,18}, + {py27,py33,py34}-django18, {py27,py34,py35}-django19, {py27,py34,py35}-django-latest @@ -17,7 +17,6 @@ commands = ./runtests.py setenv = PYTHONDONTWRITEBYTECODE=1 deps = - django17: django>=1.7.0,<1.8.0 django18: django>=1.8.0,<1.9.0 django19: django>=1.9.0,<1.10.0 django-latest: https://github.com/django/django/archive/master.tar.gz From 82df8e01a80d3ce2bbb07f148add4dac2ce9b96b Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 3 Aug 2016 14:28:44 +0100 Subject: [PATCH 41/49] Tox: Test Django 1.8 on Python 3.5 Since it was being tested on Travis, but not when using Tox. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b1f3e5b..604ca98 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - {py27,py33,py34}-django18, + {py27,py33,py34,py35}-django18, {py27,py34,py35}-django19, {py27,py34,py35}-django-latest From f2e85cdd3aeec58beff5aee18d95a58dd8c2088e Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 3 Aug 2016 14:30:41 +0100 Subject: [PATCH 42/49] Travis: Test on Django 1.10 --- .travis.yml | 3 +++ README.rst | 2 +- tox.ini | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7af599e..7f5b15b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ python: 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' @@ -30,6 +31,8 @@ matrix: exclude: - python: "3.3" env: DJANGO='https://github.com/django/django/archive/master.tar.gz' + - python: "3.3" + env: DJANGO='django>=1.10.0,<1.11.0' - python: "3.3" env: DJANGO='django>=1.9.0,<1.10.0' allow_failures: diff --git a/README.rst b/README.rst index bad8281..6a3e53f 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ Requirements ------------ * Python 2.7, 3.3, 3.4, 3.5 -* Django 1.8, 1.9 +* Django 1.8, 1.9, 1.10 Installation ------------ diff --git a/tox.ini b/tox.ini index 604ca98..b43f691 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = {py27,py33,py34,py35}-django18, - {py27,py34,py35}-django19, + {py27,py34,py35}-django{19,110}, {py27,py34,py35}-django-latest @@ -19,5 +19,6 @@ setenv = deps = django18: django>=1.8.0,<1.9.0 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 From fa9d6499ee3a0ec5ed5bf8af4198ce9738f7c1dc Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 1 Jul 2016 19:57:37 -0400 Subject: [PATCH 43/49] Add AllValuesMultipleTests --- django_filters/filters.py | 2 ++ tests/test_filtering.py | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 20c78b8..39e46cf 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -384,6 +384,7 @@ class AllValuesFilter(ChoiceFilter): self.extra['choices'] = [(o, o) for o in qs] return super(AllValuesFilter, self).field + class AllValuesMultipleFilter(MultipleChoiceFilter): @property def field(self): @@ -392,6 +393,7 @@ class AllValuesMultipleFilter(MultipleChoiceFilter): self.extra['choices'] = [(o, o) for o in qs] return super(AllValuesMultipleFilter, self).field + class BaseCSVFilter(Filter): """ Base class for CSV type filters, such as IN and RANGE. diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 3e28427..897ec2f 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -13,7 +13,7 @@ from django.utils import timezone from django_filters.filterset import FilterSet from django_filters.filters import AllValuesFilter -from django_filters.filters import BaseInFilter +from django_filters.filters import AllValuesMultipleFilter from django_filters.filters import CharFilter from django_filters.filters import ChoiceFilter from django_filters.filters import DateRangeFilter @@ -869,6 +869,29 @@ class AllValuesFilterTests(TestCase): list(User.objects.all())) +class AllValuesMultipleFilterTests(TestCase): + + def test_filtering(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + class F(FilterSet): + username = AllValuesMultipleFilter() + + 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': ['alex', 'jacob']}).qs), + list(User.objects.filter(username__in=['alex', 'jacob']))) + self.assertEqual(list(F({'username': ['jose']}).qs), + list()) + + class MethodFilterTests(TestCase): def test_filtering(self): From 69d90c049827e977bee1f658acd4a44ddc7e7e09 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 1 Aug 2016 21:40:52 -0400 Subject: [PATCH 44/49] Resolve #450 --- django_filters/filterset.py | 52 +++++++++++++++++++++----------- django_filters/utils.py | 29 +++++++++++++++++- tests/test_deprecations.py | 59 +++++++++++++++++++++++++++++++++++++ tests/test_filtering.py | 1 + tests/test_filterset.py | 30 ++++++++++++++----- tests/test_forms.py | 1 + 6 files changed, 146 insertions(+), 26 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index d272b06..b9fcf07 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import copy import re -import warnings from collections import OrderedDict from django import forms @@ -21,18 +20,12 @@ from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRange ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, DurationFilter) -from .utils import try_dbfield, get_model_field, resolve_field +from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate ORDER_BY_FIELD = 'o' -def deprecate(msg): - warnings.warn( - "%s See: https://django-filter.readthedocs.io/en/latest/migration.html" % msg, - DeprecationWarning, stacklevel=3) - - class STRICTNESS(object): """ Values of False & True chosen for backward compatability reasons. @@ -68,13 +61,15 @@ def get_declared_filters(bases, attrs, with_base_filters=True): def filters_for_model(model, fields=None, exclude=None, filter_for_field=None, filter_for_reverse_field=None): field_dict = OrderedDict() - opts = model._meta - if fields is None: - fields = [ - f.name for f in sorted(opts.fields + opts.many_to_many) - if not isinstance(f, models.AutoField) and - not (getattr(remote_field(f), 'parent_link', False)) - ] + + # Setting exclude with no fields implies all other fields. + if exclude is not None and fields is None: + fields = '__all__' + + # All implies all db fields associated with a filter_class. + if fields == '__all__': + fields = get_all_model_fields(model) + # Loop through the list of fields. for f in fields: # Skip the field if excluded. @@ -139,6 +134,19 @@ def get_full_clean_override(together): class FilterSetOptions(object): def __init__(self, options=None): + if getattr(options, 'model', None) is not None: + if not hasattr(options, 'fields') and not hasattr(options, 'exclude'): + deprecate( + "Not setting Meta.fields with Meta.model is undocumented behavior " + "and may result in unintentionally exposing filter fields. This has " + "been deprecated in favor of setting Meta.fields = '__all__' or by " + "setting the Meta.exclude attribute.", 1) + + elif getattr(options, 'fields', -1) is None: + deprecate( + "Setting 'Meta.fields = None' is undocumented behavior and has been " + "deprecated in favor of Meta.fields = '__all__'.", 1) + self.model = getattr(options, 'model', None) self.fields = getattr(options, 'fields', None) self.exclude = getattr(options, 'exclude', None) @@ -166,6 +174,9 @@ class FilterSetMetaclass(type): opts = new_class._meta = FilterSetOptions( getattr(new_class, 'Meta', None)) + + # TODO: replace with deprecations + # if opts.model and opts.fields: if opts.model: filters = new_class.filters_for_model(opts.model, opts) filters.update(declared_filters) @@ -432,8 +443,15 @@ class BaseFilterSet(object): @classmethod def filters_for_model(cls, model, opts): + # TODO: remove with deprecations - this emulates the old behavior + fields = opts.fields + if fields is None: + DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) + DEFAULTS.update(cls.filter_overrides) + fields = get_all_model_fields(model, field_types=DEFAULTS.keys()) + return filters_for_model( - model, opts.fields, opts.exclude, + model, fields, opts.exclude, cls.filter_for_field, cls.filter_for_reverse_field ) @@ -543,7 +561,7 @@ class FilterSet(six.with_metaclass(FilterSetMetaclass, BaseFilterSet)): def filterset_factory(model): - meta = type(str('Meta'), (object,), {'model': model}) + meta = type(str('Meta'), (object,), {'model': model, 'fields': '__all__'}) filterset = type(str('%sFilterSet' % model._meta.object_name), (FilterSet,), {'Meta': meta}) return filterset diff --git a/django_filters/utils.py b/django_filters/utils.py index a5cb3dd..510d91c 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -1,3 +1,5 @@ +import warnings + from django.conf import settings from django.core.exceptions import FieldError from django.db import models @@ -7,10 +9,16 @@ from django.db.models.fields import FieldDoesNotExist from django.db.models.fields.related import ForeignObjectRel from django.utils import six, timezone -from .compat import remote_model +from .compat import remote_field, remote_model from .exceptions import FieldLookupError +def deprecate(msg, level_modifier=0): + warnings.warn( + "%s See: https://django-filter.readthedocs.io/en/latest/migration.html" % msg, + DeprecationWarning, stacklevel=3 + level_modifier) + + def try_dbfield(fn, field_class): """ Try ``fn`` with the DB ``field_class`` by walking its @@ -31,6 +39,25 @@ def try_dbfield(fn, field_class): return data +# TODO: remove field_types arg with deprecations +def get_all_model_fields(model, field_types=None): + opts = model._meta + + if field_types is not None: + return [ + f.name for f in sorted(opts.fields + opts.many_to_many) + if not isinstance(f, models.AutoField) and + not (getattr(remote_field(f), 'parent_link', False)) and + f.__class__ in field_types + ] + + return [ + f.name for f in sorted(opts.fields + opts.many_to_many) + if not isinstance(f, models.AutoField) and + not (getattr(remote_field(f), 'parent_link', False)) + ] + + def get_model_field(model, field_name): """ Get a ``model`` field, traversing relationships diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index b879e4a..890f5ae 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -3,12 +3,16 @@ import warnings from django.test import TestCase from django_filters import FilterSet +from django_filters.filters import CharFilter from .models import User +from .models import NetworkSetting +from .models import SubnetMaskField class UserFilter(FilterSet): class Meta: model = User + fields = '__all__' class FilterSetContainerDeprecationTests(TestCase): @@ -47,3 +51,58 @@ class FilterSetContainerDeprecationTests(TestCase): UserFilter().count() self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + +class FilterSetMetaDeprecationTests(TestCase): + def test_fields_not_set(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + model = User + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message)) + + def test_fields_is_none(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + model = User + fields = None + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + self.assertIn("Setting 'Meta.fields = None' is undocumented behavior", str(w[-1].message)) + + def test_fields_not_set_ignore_unknown(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + model = NetworkSetting + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message)) + + self.assertNotIn('mask', F.base_filters.keys()) + + def test_fields_not_set_with_override(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter} + } + + class Meta: + model = NetworkSetting + + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + 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']) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 3e28427..2a42a59 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -390,6 +390,7 @@ class DurationFilterTests(TestCase): class Meta: model = SpacewalkRecord + fields = '__all__' qs = SpacewalkRecord.objects.order_by('duration') diff --git a/tests/test_filterset.py b/tests/test_filterset.py index dcb0aa5..316ebae 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -301,6 +301,7 @@ class FilterSetClassCreationTests(TestCase): class F(FilterSet): class Meta: model = Book + fields = '__all__' self.assertEqual(len(F.declared_filters), 0) self.assertEqual(len(F.base_filters), 3) @@ -313,6 +314,7 @@ class FilterSetClassCreationTests(TestCase): class Meta: model = Book + fields = '__all__' self.assertEqual(len(F.declared_filters), 1) self.assertEqual(len(F.base_filters), 4) @@ -421,10 +423,22 @@ class FilterSetClassCreationTests(TestCase): self.assertListEqual(list(F.base_filters), ['username', 'price']) + def test_meta_exlude_with_no_fields(self): + class F(FilterSet): + class Meta: + model = Book + exclude = ('price', ) + + self.assertEqual(len(F.declared_filters), 0) + self.assertEqual(len(F.base_filters), 2) + self.assertListEqual(list(F.base_filters), + ['title', 'average_rating']) + def test_filterset_class_inheritance(self): class F(FilterSet): class Meta: model = Book + fields = '__all__' class G(F): pass @@ -435,6 +449,7 @@ class FilterSetClassCreationTests(TestCase): class Meta: model = Book + fields = '__all__' class G(F): pass @@ -444,6 +459,7 @@ class FilterSetClassCreationTests(TestCase): class F(FilterSet): class Meta: model = Restaurant + fields = '__all__' self.assertEqual(set(F.base_filters), set(['name', 'serves_pizza'])) @@ -454,13 +470,6 @@ class FilterSetClassCreationTests(TestCase): self.assertEqual(set(F.base_filters), set(['name', 'serves_pizza'])) - def test_custom_field_ignored(self): - class F(FilterSet): - class Meta: - model = NetworkSetting - - self.assertEqual(list(F.base_filters.keys()), ['ip']) - def test_custom_field_gets_filter_from_override(self): class F(FilterSet): filter_overrides = { @@ -468,6 +477,7 @@ class FilterSetClassCreationTests(TestCase): class Meta: model = NetworkSetting + fields = '__all__' self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) @@ -475,10 +485,12 @@ class FilterSetClassCreationTests(TestCase): class F(FilterSet): class Meta: model = User + fields = '__all__' class ProxyF(FilterSet): class Meta: model = AdminUser + fields = '__all__' self.assertEqual(list(F.base_filters), list(ProxyF.base_filters)) @@ -486,10 +498,12 @@ class FilterSetClassCreationTests(TestCase): class F(FilterSet): class Meta: model = Account + fields = '__all__' class FtiF(FilterSet): class Meta: model = BankAccount + fields = '__all__' # fails due to 'account_ptr' getting picked up self.assertEqual( @@ -760,7 +774,7 @@ class FilterSetTogetherTests(TestCase): self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk) -@unittest.skip('remove when relevant deprecations have been completed') +@unittest.skip('TODO: remove when relevant deprecations have been completed') class MiscFilterSetTests(TestCase): def test_no__getitem__(self): diff --git a/tests/test_forms.py b/tests/test_forms.py index 3a6d0ef..1666291 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -39,6 +39,7 @@ class FilterSetFormTests(TestCase): class F(FilterSet): class Meta: model = Book + fields = '__all__' form = MyForm f = F().form From 86f87f887fc0161e8c4f5943811d66852e9043a1 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 1 Aug 2016 21:42:41 -0400 Subject: [PATCH 45/49] Filter generation should assert recognizable field --- django_filters/filterset.py | 9 +++++++-- tests/test_filterset.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index b9fcf07..e5f31ce 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -469,8 +469,13 @@ class BaseFilterSet(object): filter_class, params = cls.filter_for_lookup(f, lookup_type) default.update(params) - if filter_class is not None: - return filter_class(**default) + assert filter_class is not None, ( + "%s resolved field '%s' with '%s' lookup to an unrecognized field " + "type %s. Try adding an override to 'filter_overrides'. See: " + "https://django-filter.readthedocs.io/en/latest/usage.html#overriding-default-filters" + ) % (cls.__name__, name, lookup_expr, f.__class__.__name__) + + return filter_class(**default) @classmethod def filter_for_reverse_field(cls, f, name): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 316ebae..b2f76b6 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -153,6 +153,17 @@ class FilterSetFilterForFieldTests(TestCase): result = FilterSet.filter_for_field(f, 'first_name') self.assertIsInstance(result, CharFilter) + def test_unknown_field_type_error(self): + f = NetworkSetting._meta.get_field('mask') + + with self.assertRaises(AssertionError) as excinfo: + FilterSet.filter_for_field(f, 'mask') + + self.assertIn( + "FilterSet resolved field 'mask' with 'exact' lookup " + "to an unrecognized field type SubnetMaskField", + excinfo.exception.args[0]) + def test_symmetrical_selfref_m2m_field(self): f = Node._meta.get_field('adjacents') result = FilterSet.filter_for_field(f, 'adjacents') From 1aac7c96d891b3f0a3d926ad1aedd42ff41c4747 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 1 Aug 2016 21:52:47 -0400 Subject: [PATCH 46/49] Add deprecation docs, expand docs on Meta opts --- docs/migration.txt | 25 +++++++++++++ docs/ref/filters.txt | 2 +- docs/ref/filterset.txt | 81 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/migration.txt b/docs/migration.txt index 20cac53..5f7afb7 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -27,3 +27,28 @@ no longer proxied from the queryset. To fix this, call the methods on the # 1.0 for obj in f.qs: ... + + +Filters no longer autogenerated when Meta.fields is not specified +----------------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/450 + +FilterSets had an undocumented behavior of autogenerating filters for all +model fields when either ``Meta.fields`` was not specified or when set to +``None``. This can lead to potentially unsafe data or schema exposure and +has been deprecated in favor of explicitly setting ``Meta.fields`` to the +``'__all__'`` special value. You may also blacklist fields by setting +the ``Meta.exclude`` attribute. + +.. code-block:: python + + class UserFilter(FilterSet): + class Meta: + model = User + fields = '__all__' + + # or + class UserFilter(FilterSet): + class Meta: + model = User + exclude = ['password'] diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 603cb07..aabe031 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -461,7 +461,7 @@ each of those is present as an option. This is similar to the default behavior of the admin. ``AllValuesMultipleFilter`` -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~ This is a ``MultipleChoiceFilter`` whose choices are the current values in the database. So if in the DB for the given field you have values of 5, 7, and 9 diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index 0967bf4..b934327 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -6,14 +6,89 @@ This document provides a guide on using additional FilterSet features. Meta options ------------ -- model -- fields -- exclude +- :ref:`model ` +- :ref:`fields ` +- :ref:`exclude ` - :ref:`order_by ` - :ref:`form
` - :ref:`together ` +.. _model: + +Automatic filter generation with ``model`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``FilterSet`` is capable of automatically generating filters for a given +``model``'s fields. Similar to Django's ``ModelForm``, filters are created +based on the underlying model field's type. This option must be combined with +either the ``fields`` or ``exclude`` option, which is the same requirement for +Django's ``ModelForm`` class, detailed `here`__. + +__ https://docs.djangoproject.com/en/dev/topics/forms/modelforms/#selecting-the-fields-to-use + +.. code-block:: python + + class UserFilter(django_filters.FilterSet): + class Meta: + model = User + fields = ['username', 'last_login'] + + +.. _fields: + +Declaring filterable ``fields`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``fields`` option is combined with ``model`` to automatically generate +filters. Note that generated filters will not overwrite filters declared on +the ``FilterSet``. The ``fields`` option accepts two syntaxes: + +* a list of field names +* a dictionary of field names mapped to a list of lookups + +.. code-block:: python + + class UserFilter(django_filters.FilterSet): + class Meta: + model = User + fields = ['username', 'last_login'] + + # or + + class UserFilter(django_filters.FilterSet): + class Meta: + model = User + fields = { + 'username': ['exact', 'contains'], + 'last_login': ['exact', 'year__gt'], + } + +The list syntax will create an ``exact`` lookup filter for each field included +in ``fields``. The dictionary syntax will create a filter for each lookup +expression declared for its corresponding model field. These expressions may +include both transforms and lookups, as detailed in the `lookup reference`__. + +__ https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups + + +.. _exclude: + +Disable filter fields with ``exclude`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``exclude`` option accepts a blacklist of field names to exclude from +automatic filter generation. Note that this option will not disable filters +declared directly on the ``FilterSet``. + +.. code-block:: python + + class UserFilter(django_filters.FilterSet): + class Meta: + model = User + exclude = ['password'] + + .. _order-by: Ordering using ``order_by`` From 3ce6060ecaa1b09f2eb186143e0da75ef1707a6f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 10 Aug 2016 17:02:22 -0400 Subject: [PATCH 47/49] Remove duplicate length checks --- tests/test_filtering.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 3e28427..ec28c55 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -1523,27 +1523,21 @@ class CSVFilterTests(TestCase): qs = User.objects.all() f = F(queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'status__in': ''}, queryset=qs) - self.assertEqual(len(f.qs), 0) self.assertEqual(f.qs.count(), 0) f = F({'status__in': '0'}, queryset=qs) - self.assertEqual(len(f.qs), 1) self.assertEqual(f.qs.count(), 1) f = F({'status__in': '0,2'}, queryset=qs) - self.assertEqual(len(f.qs), 3) self.assertEqual(f.qs.count(), 3) f = F({'status__in': '0,,1'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) f = F({'status__in': '2'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) def test_string_filtering(self): @@ -1551,27 +1545,21 @@ class CSVFilterTests(TestCase): qs = User.objects.all() f = F(queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'username__in': ''}, queryset=qs) - self.assertEqual(len(f.qs), 0) self.assertEqual(f.qs.count(), 0) f = F({'username__in': 'alex'}, queryset=qs) - self.assertEqual(len(f.qs), 1) self.assertEqual(f.qs.count(), 1) f = F({'username__in': 'alex,aaron'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) f = F({'username__in': 'alex,,aaron'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) f = F({'username__in': 'alex,'}, queryset=qs) - self.assertEqual(len(f.qs), 1) self.assertEqual(f.qs.count(), 1) def test_datetime_filtering(self): @@ -1585,23 +1573,18 @@ class CSVFilterTests(TestCase): self.assertEqual(f.qs.count(), 4) f = F({'published__in': ''}, queryset=qs) - self.assertEqual(len(f.qs), 0) self.assertEqual(f.qs.count(), 0) f = F({'published__in': '%s' % (after, )}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) f = F({'published__in': '%s,%s' % (after, before, )}, queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'published__in': '%s,,%s' % (after, before, )}, queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'published__in': '%s,' % (after, )}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) def test_related_filtering(self): @@ -1609,27 +1592,21 @@ class CSVFilterTests(TestCase): qs = Article.objects.all() f = F(queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'author__in': ''}, queryset=qs) - self.assertEqual(len(f.qs), 0) self.assertEqual(f.qs.count(), 0) f = F({'author__in': '1'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) f = F({'author__in': '1,2'}, queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'author__in': '1,,2'}, queryset=qs) - self.assertEqual(len(f.qs), 4) self.assertEqual(f.qs.count(), 4) f = F({'author__in': '1,'}, queryset=qs) - self.assertEqual(len(f.qs), 2) self.assertEqual(f.qs.count(), 2) From 9702e557446b3feecdaff66ac0c1acae09082bd6 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 10 Aug 2016 17:14:57 -0400 Subject: [PATCH 48/49] Empty value for CSV should parse as [] --- django_filters/widgets.py | 2 ++ tests/test_filtering.py | 12 ++++++++++++ tests/test_widgets.py | 10 +++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/django_filters/widgets.py b/django_filters/widgets.py index 04a5678..fc14c04 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -144,6 +144,8 @@ class CSVWidget(forms.TextInput): value = super(CSVWidget, self).value_from_datadict(data, files, name) if value is not None: + if value == '': # empty value should parse as an empty list + return [] return value.split(',') return None diff --git a/tests/test_filtering.py b/tests/test_filtering.py index ec28c55..4182554 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -1526,6 +1526,9 @@ class CSVFilterTests(TestCase): self.assertEqual(f.qs.count(), 4) f = F({'status__in': ''}, queryset=qs) + self.assertEqual(f.qs.count(), 4) + + f = F({'status__in': ','}, queryset=qs) self.assertEqual(f.qs.count(), 0) f = F({'status__in': '0'}, queryset=qs) @@ -1548,6 +1551,9 @@ class CSVFilterTests(TestCase): self.assertEqual(f.qs.count(), 4) f = F({'username__in': ''}, queryset=qs) + self.assertEqual(f.qs.count(), 4) + + f = F({'username__in': ','}, queryset=qs) self.assertEqual(f.qs.count(), 0) f = F({'username__in': 'alex'}, queryset=qs) @@ -1573,6 +1579,9 @@ class CSVFilterTests(TestCase): self.assertEqual(f.qs.count(), 4) f = F({'published__in': ''}, queryset=qs) + self.assertEqual(f.qs.count(), 4) + + f = F({'published__in': ','}, queryset=qs) self.assertEqual(f.qs.count(), 0) f = F({'published__in': '%s' % (after, )}, queryset=qs) @@ -1595,6 +1604,9 @@ class CSVFilterTests(TestCase): self.assertEqual(f.qs.count(), 4) f = F({'author__in': ''}, queryset=qs) + self.assertEqual(f.qs.count(), 4) + + f = F({'author__in': ','}, queryset=qs) self.assertEqual(f.qs.count(), 0) f = F({'author__in': '1'}, queryset=qs) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index f8873c3..57caea3 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -212,9 +212,17 @@ class CSVWidgetTests(TestCase): result = w.value_from_datadict(data, {}, 'price') self.assertEqual(result, ['1', '', '2']) + data = {'price': '1,'} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['1', '']) + + data = {'price': ','} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['', '']) + data = {'price': ''} result = w.value_from_datadict(data, {}, 'price') - self.assertEqual(result, ['']) + self.assertEqual(result, []) result = w.value_from_datadict({}, {}, 'price') self.assertEqual(result, None) From 69905eb869dd7e23d97db030c8382cca07c9d126 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Sun, 14 Aug 2016 21:08:52 +0200 Subject: [PATCH 49/49] Update CHANGES and version for 0.14 Release --- .bumpversion.cfg | 2 +- CHANGES.rst | 13 ++++++++++++- django_filters/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 65c6c1b..06a2bb1 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.13.0 +current_version = 0.14.0 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/CHANGES.rst b/CHANGES.rst index b6c61c8..7bef6ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,19 @@ -Version 0.14.0 (unreleased) +Version 0.14.0 (2016-08-14) --------------------------- * Add support for filtering on DurationField (new in Django 1.8). +* Fix UUIDFilter import issue + +* Improve FieldLookupError message + +* Add filters_for_model to improve extensibility + +* Fix limit_choices_to behavior with callables + +* Fix distinct behavior for range filters + +* Various Minor Clean up issues. Version 0.13.0 (2016-03-11) --------------------------- diff --git a/django_filters/__init__.py b/django_filters/__init__.py index eeb0868..6083b20 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.13.0' +__version__ = '0.14.0' def parse_version(version): diff --git a/docs/conf.py b/docs/conf.py index 1db554f..2b8e198 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.13.0' +version = '0.14.0' # The full version, including alpha/beta/rc tags. -release = '0.13.0' +release = '0.14.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 4f14227..3f9264a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ f = open('README.rst') readme = f.read() f.close() -version = '0.13.0' +version = '0.14.0' if sys.argv[-1] == 'publish': if os.system("pip freeze | grep wheel"):