diff --git a/.bumpversion.cfg b/.bumpversion.cfg index dd3c97b7f..4ff40beec 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.15.0 +current_version = 0.15.1 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/CHANGES.rst b/CHANGES.rst index 4781f26f5..b795e04dd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,14 @@ +Version 0.15.1 (2016-09-28) +--------------------------- + +A couple of quick bug fixes: + +* #496 OrderingFilter not working with Select widget + +* #498 DRF Backend Templates not loading + + + Version 0.15.0 (2016-09-20) --------------------------- diff --git a/django_filters/__init__.py b/django_filters/__init__.py index d86c9c6b3..ce8984501 100644 --- a/django_filters/__init__.py +++ b/django_filters/__init__.py @@ -3,7 +3,7 @@ from .filterset import FilterSet from .filters import * -__version__ = '0.15.0' +__version__ = '0.15.1' def parse_version(version): diff --git a/django_filters/fields.py b/django_filters/fields.py index 62e28e825..e638787f8 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _ from .utils import handle_timezone -from .widgets import RangeWidget, LookupTypeWidget, CSVWidget +from .widgets import RangeWidget, LookupTypeWidget, CSVWidget, BaseCSVWidget class RangeField(forms.MultiValueField): @@ -129,7 +129,28 @@ class IntegerCSVField(BaseCSVField, filters.IntegerField): pass """ - widget = CSVWidget + base_widget_class = BaseCSVWidget + + def __init__(self, *args, **kwargs): + widget = kwargs.get('widget') or self.widget + kwargs['widget'] = self._get_widget_class(widget) + + super(BaseCSVField, self).__init__(*args, **kwargs) + + def _get_widget_class(self, widget): + # passthrough, allows for override + if isinstance(widget, BaseCSVWidget) or ( + isinstance(widget, type) and + issubclass(widget, BaseCSVWidget)): + return widget + + # complain since we are unable to reconstruct widget instances + assert isinstance(widget, type), \ + "'%s.widget' must be a widget class, not %s." \ + % (self.__class__.__name__, repr(widget)) + + bases = (self.base_widget_class, widget, ) + return type(str('CSV%s' % widget.__name__), bases, {}) def clean(self, value): if value is None: @@ -138,6 +159,10 @@ def clean(self, value): class BaseRangeField(BaseCSVField): + # Force use of text input, as range must always have two inputs. A date + # input would only allow a user to input one value and would always fail. + widget = CSVWidget + default_error_messages = { 'invalid_values': _('Range query expects two values.') } diff --git a/django_filters/rest_framework/backends.py b/django_filters/rest_framework/backends.py index 6655b443b..074a43163 100644 --- a/django_filters/rest_framework/backends.py +++ b/django_filters/rest_framework/backends.py @@ -1,22 +1,45 @@ from __future__ import absolute_import -from django.template import loader +from django.template import Template, TemplateDoesNotExist, loader +from rest_framework.compat import template_render from rest_framework.filters import BaseFilterBackend from .. import compat from . import filterset +CRISPY_TEMPLATE = """ +{% load crispy_forms_tags %} +{% load i18n %} + +

{% trans "Field filters" %}

+{% crispy filter.form %} +""" + + +FILTER_TEMPLATE = """ +{% load i18n %} +

{% trans "Field filters" %}

+
+ {{ filter.form.as_p }} + +
+""" + + if compat.is_crispy: - filter_template = 'django_filters/rest_framework/crispy_form.html' + template_path = 'django_filters/rest_framework/crispy_form.html' + template_default = Template(CRISPY_TEMPLATE) + else: - filter_template = 'django_filters/rest_framework/form.html' + template_path = 'django_filters/rest_framework/form.html' + template_default = Template(FILTER_TEMPLATE) class DjangoFilterBackend(BaseFilterBackend): default_filter_set = filterset.FilterSet - template = filter_template + template = template_path def get_filter_class(self, view, queryset=None): """ @@ -57,8 +80,12 @@ def to_html(self, request, queryset, view): if not filter_class: return None filter_instance = filter_class(request.query_params, queryset=queryset) - context = { + + try: + template = loader.get_template(self.template) + except TemplateDoesNotExist: + template = template_default + + return template_render(template, context={ 'filter': filter_instance - } - template = loader.get_template(self.template) - return compat.template_render(template, context) + }) diff --git a/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html b/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html deleted file mode 100644 index 171767c08..000000000 --- a/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load crispy_forms_tags %} -{% load i18n %} - -

{% trans "Field filters" %}

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

{% trans "Field filters" %}

-
- {{ filter.form.as_p }} - -
diff --git a/django_filters/widgets.py b/django_filters/widgets.py index f910a8034..71273067c 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -138,12 +138,12 @@ def value_from_datadict(self, data, files, name): }.get(value, None) -class CSVWidget(forms.TextInput): +class BaseCSVWidget(forms.Widget): def _isiterable(self, value): return isinstance(value, Iterable) and not isinstance(value, string_types) def value_from_datadict(self, data, files, name): - value = super(CSVWidget, self).value_from_datadict(data, files, name) + value = super(BaseCSVWidget, self).value_from_datadict(data, files, name) if value is not None: if value == '': # empty value should parse as an empty list @@ -152,8 +152,22 @@ def value_from_datadict(self, data, files, name): return None def render(self, name, value, attrs=None): - if self._isiterable(value): - value = [force_text(format_value(self, v)) for v in value] - value = ','.join(list(value)) + if not self._isiterable(value): + value = [value] - return super(CSVWidget, self).render(name, value, attrs) + if len(value) <= 1: + # delegate to main widget (Select, etc...) if not multiple values + value = value[0] if value else value + return super(BaseCSVWidget, self).render(name, value, attrs) + + # if we have multiple values, we need to force render as a text input + # (otherwise, the additional values are lost) + surrogate = forms.TextInput() + value = [force_text(format_value(surrogate, v)) for v in value] + value = ','.join(list(value)) + + return surrogate.render(name, value, attrs) + + +class CSVWidget(BaseCSVWidget, forms.TextInput): + pass diff --git a/docs/conf.py b/docs/conf.py index d795de34b..4e5cf76a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.15.0' +version = '0.15.1' # The full version, including alpha/beta/rc tags. -release = '0.15.0' +release = '0.15.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 1f8ad2ede..f3f615294 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -674,7 +674,7 @@ two additional arguments that are used to build the ordering choices. ('username', 'account'), ('first_name', 'first_name'), ('last_name', 'last_name'), - }, + ), # labels do not need to retain order field_labels={ @@ -686,7 +686,7 @@ two additional arguments that are used to build the ordering choices. model = User fields = ['first_name', 'last_name'] - >>> UserFilter().filter['o'].field.choices + >>> UserFilter().filters['o'].field.choices [ ('account', 'User account'), ('-account', 'User account (descending)'), diff --git a/setup.py b/setup.py index ab9337046..5964ea316 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ readme = f.read() f.close() -version = '0.15.0' +version = '0.15.1' if sys.argv[-1] == 'publish': if os.system("pip freeze | grep wheel"): diff --git a/tests/rest_framework/__init__.py b/tests/rest_framework/__init__.py index e69de29bb..488e6f11e 100644 --- a/tests/rest_framework/__init__.py +++ b/tests/rest_framework/__init__.py @@ -0,0 +1 @@ +default_app_config = 'tests.rest_framework.apps.RestFrameworkTestConfig' diff --git a/tests/rest_framework/apps.py b/tests/rest_framework/apps.py new file mode 100644 index 000000000..0a1efc13f --- /dev/null +++ b/tests/rest_framework/apps.py @@ -0,0 +1,8 @@ + +from django.apps import AppConfig + + +class RestFrameworkTestConfig(AppConfig): + name = 'tests.rest_framework' + label = 'drf_test_app' + verbose_name = "Rest Framework Test App" diff --git a/tests/rest_framework/templates/filter_template.html b/tests/rest_framework/templates/filter_template.html new file mode 100644 index 000000000..345e6aef7 --- /dev/null +++ b/tests/rest_framework/templates/filter_template.html @@ -0,0 +1 @@ +Test diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py index 2a569abaf..f867b722f 100644 --- a/tests/rest_framework/test_backends.py +++ b/tests/rest_framework/test_backends.py @@ -280,6 +280,53 @@ def test_unknown_filter(self): response = view(request).render() self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_html_rendering(self): + """ + Make sure response renders w/ backend + """ + view = FilterFieldsRootView.as_view() + request = factory.get('/') + request.META['HTTP_ACCEPT'] = 'text/html' + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_backend_output(self): + """ + Ensure backend renders default if template path does not exist + """ + view = FilterFieldsRootView() + backend = view.filter_backends[0] + request = view.initialize_request(factory.get('/')) + html = backend().to_html(request, view.get_queryset(), view) + + self.assertHTMLEqual(html, """ +

Field filters

+
+

+ + + Filter +

+

+ + + Filter +

+ +
+ """) + + def test_template_path(self): + view = FilterFieldsRootView() + + class Backend(view.filter_backends[0]): + template = 'filter_template.html' + + request = view.initialize_request(factory.get('/')) + html = Backend().to_html(request, view.get_queryset(), view) + + self.assertHTMLEqual(html, "Test") + @override_settings(ROOT_URLCONF='tests.rest_framework.test_backends') class IntegrationTestDetailFiltering(CommonFilteringTestCase): diff --git a/tests/settings.py b/tests/settings.py index 13f90131c..eeeb7525f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -7,12 +7,15 @@ INSTALLED_APPS = ( 'django.contrib.contenttypes', + 'django.contrib.staticfiles', 'django.contrib.auth', - 'django_filters', - 'tests', + 'rest_framework', 'tests.rest_framework', + 'tests', ) +MIDDLEWARE = [] + ROOT_URLCONF = 'tests.urls' USE_TZ = True @@ -25,7 +28,7 @@ }] -MIDDLEWARE = [] +STATIC_URL = '/static/' # help verify that DEFAULTS is importable from conf. diff --git a/tests/test_fields.py b/tests/test_fields.py index dd7615196..23b273e74 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -3,14 +3,12 @@ from datetime import datetime, time, timedelta, tzinfo import decimal -import unittest -import django from django import forms from django.test import TestCase, override_settings from django.utils.timezone import make_aware, get_default_timezone -from django_filters.widgets import RangeWidget +from django_filters.widgets import BaseCSVWidget, CSVWidget, RangeWidget from django_filters.fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField @@ -196,6 +194,25 @@ def test_validation_error(self): with self.assertRaises(forms.ValidationError): self.field.clean(['a', 'b', 'c']) + def test_derived_widget(self): + with self.assertRaises(AssertionError) as excinfo: + BaseCSVField(widget=RangeWidget()) + + msg = str(excinfo.exception) + self.assertIn("'BaseCSVField.widget' must be a widget class", msg) + self.assertIn("RangeWidget", msg) + + widget = CSVWidget() + field = BaseCSVField(widget=widget) + self.assertIs(field.widget, widget) + + field = BaseCSVField(widget=CSVWidget) + self.assertIsInstance(field.widget, CSVWidget) + + field = BaseCSVField(widget=forms.Select) + self.assertIsInstance(field.widget, forms.Select) + self.assertIsInstance(field.widget, BaseCSVWidget) + class BaseRangeFieldTests(TestCase): def setUp(self): diff --git a/tests/test_filtering.py b/tests/test_filtering.py index acf597cf9..b706c8c0b 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -6,6 +6,7 @@ import unittest import django +from django import forms from django.test import TestCase, override_settings from django.utils import six from django.utils.timezone import now @@ -24,6 +25,7 @@ from django_filters.filters import MultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import NumberFilter +from django_filters.filters import OrderingFilter from django_filters.filters import RangeFilter from django_filters.filters import TimeRangeFilter # from django_filters.widgets import LinkWidget @@ -1586,6 +1588,46 @@ def test_related_filtering(self): self.assertEqual(f.qs.count(), 2) +class OrderingFilterTests(TestCase): + + def setUp(self): + User.objects.create(username='alex', status=1) + User.objects.create(username='jacob', status=2) + User.objects.create(username='aaron', status=2) + User.objects.create(username='carl', status=0) + + def test_ordering(self): + class F(FilterSet): + o = OrderingFilter( + fields=('username', ) + ) + + class Meta: + model = User + fields = ['username'] + + qs = User.objects.all() + f = F({'o': 'username'}, queryset=qs) + names = f.qs.values_list('username', flat=True) + self.assertEqual(list(names), ['aaron', 'alex', 'carl', 'jacob']) + + def test_ordering_with_select_widget(self): + class F(FilterSet): + o = OrderingFilter( + widget=forms.Select, + fields=('username', ) + ) + + class Meta: + model = User + fields = ['username'] + + qs = User.objects.all() + f = F({'o': 'username'}, queryset=qs) + names = f.qs.values_list('username', flat=True) + self.assertEqual(list(names), ['aaron', 'alex', 'carl', 'jacob']) + + class MiscFilterSetTests(TestCase): def setUp(self): diff --git a/tests/test_filters.py b/tests/test_filters.py index b360a5d90..27758ee73 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -9,7 +9,7 @@ from django import forms from django.test import TestCase, override_settings -from django_filters import filters +from django_filters import filters, widgets from django_filters.fields import ( Lookup, RangeField, @@ -1097,3 +1097,10 @@ def test_normalize_fields(self): with self.assertRaises(AssertionError) as ctx: f([0, 1, 2]) self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.") + + def test_widget(self): + f = OrderingFilter() + widget = f.field.widget + + self.assertIsInstance(widget, widgets.BaseCSVWidget) + self.assertIsInstance(widget, forms.Select) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 2cfb93c77..2856cb1dc 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -5,6 +5,7 @@ from django.forms import TextInput, Select from django_filters.widgets import BooleanWidget +from django_filters.widgets import BaseCSVWidget from django_filters.widgets import CSVWidget from django_filters.widgets import RangeWidget from django_filters.widgets import LinkWidget @@ -182,6 +183,9 @@ def test_widget(self): self.assertHTMLEqual(w.render('price', ''), """ """) + self.assertHTMLEqual(w.render('price', '1'), """ + """) + self.assertHTMLEqual(w.render('price', '1,2'), """ """) @@ -224,3 +228,54 @@ def test_widget_value_from_datadict(self): result = w.value_from_datadict({}, {}, 'price') self.assertEqual(result, None) + + +class CSVSelectTests(TestCase): + class CSVSelect(BaseCSVWidget, Select): + pass + + def test_widget(self): + w = self.CSVSelect(choices=((1, 'a'), (2, 'b'))) + self.assertHTMLEqual( + w.render('price', None), + """ + + """ + ) + + self.assertHTMLEqual( + w.render('price', ''), + """ + + """) + + self.assertHTMLEqual( + w.render('price', '1'), + """ + + """) + + self.assertHTMLEqual( + w.render('price', '1,2'), + """ + + """ + ) + + self.assertHTMLEqual(w.render('price', ['1', '2']), """ + """) + + self.assertHTMLEqual(w.render('price', [1, 2]), """ + """)