diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 06a2bb1..dd3c97b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.0 +current_version = 0.15.0 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/.travis.yml b/.travis.yml index 7f5b15b..5446ee6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,17 +11,18 @@ python: - "3.5" env: - - DJANGO='https://github.com/django/django/archive/master.tar.gz' - - DJANGO='django>=1.10.0,<1.11.0' - - DJANGO='django>=1.9.0,<1.10.0' - - DJANGO='django>=1.8.0,<1.9.0' + - DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' + - DJANGO="django>=1.10.0,<1.11.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + - DJANGO="django>=1.8.0,<1.9.0" REST_FRAMEWORK="djangorestframework>=3.3,<3.4" install: - - travis_retry pip install $DJANGO - - travis_retry pip install -r requirements/travis-ci.txt + - travis_retry pip install -Ur requirements/test-ci.txt + - travis_retry pip install $DJANGO $REST_FRAMEWORK script: - - coverage run --source django_filters runtests.py -v 2 + - $W coverage run --source django_filters runtests.py -v 2 - coverage report notifications: @@ -30,11 +31,15 @@ notifications: matrix: exclude: - python: "3.3" - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' + env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' - python: "3.3" - env: DJANGO='django>=1.10.0,<1.11.0' + env: DJANGO="django>=1.10.0,<1.11.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" - python: "3.3" - env: DJANGO='django>=1.9.0,<1.10.0' + env: DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5" + include: + - python: "3.5" + env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' allow_failures: - - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' + - env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' + - env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5' fast_finish: true diff --git a/CHANGES.rst b/CHANGES.rst index 7bef6ba..4781f26 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,36 @@ +Version 0.15.0 (2016-09-20) +--------------------------- + +This is a preparatory release for a 1.0. Lots of clean-up, lots of changes, +mostly backwards compatible. + +Special thanks to Ryan P Kilby (@rpkilby) for lots of hard work. + +Most changes should raise a Deprecation Warning. + +**Note**: if you're doing *Clever Things™* with the various filter options +— ``filter_overrides`` etc — you may run into an `AttributeError` since these +are now defined on the metaclass and not on the filter itself. +(See the discussion on #459) + +Summary: Highly Recommended, but take a moment to ensure everything still works. + +* Added the DRF backend. #481 + +* Deprecated `MethodFilter` in favour of `Filter.method` #382 + +* Move filter options to metaclass #459 + +* Added `get_filter_predicate` hook. (Allows e.g. filtering on annotated fields) #469 + +* Rework Ordering options into a filter #472 + +* Hardened all deprecations for 1.0. Please do see the `Migration Notes`__ + +__ https://github.com/carltongibson/django-filter/blob/develop/docs/migration.txt + + + Version 0.14.0 (2016-08-14) --------------------------- @@ -15,6 +48,7 @@ Version 0.14.0 (2016-08-14) * Various Minor Clean up issues. + Version 0.13.0 (2016-03-11) --------------------------- diff --git a/README.rst b/README.rst index 6a3e53f..6de34f1 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,7 @@ Requirements * Python 2.7, 3.3, 3.4, 3.5 * Django 1.8, 1.9, 1.10 +* DRF 3.3 (Django 1.8 only), 3.4 Installation ------------ diff --git a/django_filters/__init__.py b/django_filters/__init__.py index 6083b20..d86c9c6 100644 --- a/django_filters/__init__.py +++ b/django_filters/__init__.py @@ -3,7 +3,7 @@ from __future__ import absolute_import from .filterset import FilterSet from .filters import * -__version__ = '0.14.0' +__version__ = '0.15.0' def parse_version(version): diff --git a/django_filters/compat.py b/django_filters/compat.py index 296551d..e7fa88b 100644 --- a/django_filters/compat.py +++ b/django_filters/compat.py @@ -1,5 +1,15 @@ import django +from django.conf import settings + + +# django-crispy-forms is optional +try: + import crispy_forms +except ImportError: + crispy_forms = None + +is_crispy = 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms def remote_field(field): @@ -22,3 +32,9 @@ def remote_queryset(field): limit_choices_to = field.get_limit_choices_to() return model._default_manager.complex_filter(limit_choices_to) + + +def format_value(widget, value): + if django.VERSION >= (1, 10): + return widget.format_value(value) + return widget._format_value(value) diff --git a/django_filters/conf.py b/django_filters/conf.py new file mode 100644 index 0000000..8537f69 --- /dev/null +++ b/django_filters/conf.py @@ -0,0 +1,106 @@ + +from django.conf import settings as dj_settings +from django.core.signals import setting_changed +from django.utils.translation import ugettext_lazy as _ + +from .utils import deprecate + + +DEFAULTS = { + 'HELP_TEXT_FILTER': True, + 'HELP_TEXT_EXCLUDE': True, + 'VERBOSE_LOOKUPS': { + # transforms don't need to be verbose, since their expressions are chained + 'date': _('date'), + 'year': _('year'), + 'month': _('month'), + 'day': _('day'), + 'week_day': _('week day'), + 'hour': _('hour'), + 'minute': _('minute'), + 'second': _('second'), + + # standard lookups + 'exact': _(''), + 'iexact': _(''), + 'contains': _('contains'), + 'icontains': _('contains'), + 'in': _('is in'), + 'gt': _('is greater than'), + 'gte': _('is greater than or equal to'), + 'lt': _('is less than'), + 'lte': _('is less than or equal to'), + 'startswith': _('starts with'), + 'istartswith': _('starts with'), + 'endswith': _('ends with'), + 'iendswith': _('ends with'), + 'range': _('is in range'), + 'isnull': _(''), + 'regex': _('matches regex'), + 'iregex': _('matches regex'), + 'search': _('search'), + + # postgres lookups + 'contained_by': _('is contained by'), + 'overlap': _('overlaps'), + 'has_key': _('has key'), + 'has_keys': _('has keys'), + 'has_any_keys': _('has any keys'), + 'trigram_similar': _('search'), + }, +} + + +DEPRECATED_SETTINGS = [ + 'HELP_TEXT_FILTER', + 'HELP_TEXT_EXCLUDE' +] + + +class Settings(object): + + def __init__(self): + for setting in DEFAULTS: + value = self.get_setting(setting) + setattr(self, setting, value) + + def VERBOSE_LOOKUPS(): + """ + VERBOSE_LOOKUPS accepts a dictionary of {terms: verbose expressions} + or a zero-argument callable that returns a dictionary. + """ + def fget(self): + if callable(self._VERBOSE_LOOKUPS): + self._VERBOSE_LOOKUPS = self._VERBOSE_LOOKUPS() + return self._VERBOSE_LOOKUPS + + def fset(self, value): + self._VERBOSE_LOOKUPS = value + + return locals() + VERBOSE_LOOKUPS = property(**VERBOSE_LOOKUPS()) + + def get_setting(self, setting): + django_setting = 'FILTERS_%s' % setting + + if setting in DEPRECATED_SETTINGS and hasattr(dj_settings, django_setting): + deprecate("The '%s' setting has been deprecated." % django_setting) + + return getattr(dj_settings, django_setting, DEFAULTS[setting]) + + def change_setting(self, setting, value, enter, **kwargs): + if not setting.startswith('FILTERS_'): + return + setting = setting[8:] # strip 'FILTERS_' + + # ensure a valid app setting is being overridden + if setting not in DEFAULTS: + return + + # if exiting, refetch the value from settings. + value = value if enter else self.get_setting(setting) + setattr(self, setting, value) + + +settings = Settings() +setting_changed.connect(settings.change_setting) diff --git a/django_filters/filters.py b/django_filters/filters.py index 39e46cf..1111cf0 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -2,21 +2,24 @@ from __future__ import absolute_import from __future__ import unicode_literals import warnings +from collections import OrderedDict from datetime import timedelta from django import forms from django.db.models import Q from django.db.models.sql.constants import QUERY_TERMS from django.db.models.constants import LOOKUP_SEP -from django.conf import settings from django.utils import six +from django.utils.itercompat import is_iterable from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ +from .conf import settings from .fields import ( Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField ) +from .utils import deprecate, pretty_name __all__ = [ @@ -38,6 +41,7 @@ __all__ = [ 'MultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', + 'OrderingFilter', 'RangeFilter', 'TimeFilter', 'TimeRangeFilter', @@ -49,6 +53,9 @@ __all__ = [ LOOKUP_TYPES = sorted(QUERY_TERMS) +EMPTY_VALUES = ([], (), {}, '', None) + + def _lookup_type_warning(): warnings.warn('lookup_type is deprecated. Use lookup_expr instead.', DeprecationWarning, stacklevel=3) @@ -57,12 +64,14 @@ class Filter(object): creation_counter = 0 field_class = forms.Field - def __init__(self, name=None, label=None, widget=None, action=None, + def __init__(self, name=None, label=None, widget=None, action=None, method=None, lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs): self.name = name self.label = label if action: + deprecate('Filter.action has been deprecated in favor of Filter.method') self.filter = action + self.method = method self.lookup_expr = lookup_expr if 'lookup_type' in kwargs: @@ -84,6 +93,28 @@ class Filter(object): """ return qs.exclude if self.exclude else qs.filter + def method(): + """ + Filter method needs to be lazily resolved, as it may be dependent on + the 'parent' FilterSet. + """ + def fget(self): + return self._method + + def fset(self, value): + self._method = value + + # clear existing FilterMethod + if isinstance(self.filter, FilterMethod): + del self.filter + + # override filter w/ FilterMethod. + if value is not None: + self.filter = FilterMethod(self) + + return locals() + method = property(**method()) + def lookup_type(): def fget(self): _lookup_type_warning() @@ -101,9 +132,9 @@ class Filter(object): if not hasattr(self, '_field'): help_text = self.extra.pop('help_text', None) if help_text is None: - if self.exclude and getattr(settings, "FILTERS_HELP_TEXT_EXCLUDE", True): + if self.exclude and settings.HELP_TEXT_EXCLUDE: help_text = _('This is an exclusion filter') - elif not self.exclude and getattr(settings, "FILTERS_HELP_TEXT_FILTER", True): + elif not self.exclude and settings.HELP_TEXT_FILTER: help_text = _('Filter') else: help_text = '' @@ -144,7 +175,7 @@ class Filter(object): value = value.value else: lookup = self.lookup_expr - if value in ([], (), {}, None, ''): + if value in EMPTY_VALUES: return qs if self.distinct: qs = qs.distinct() @@ -174,38 +205,40 @@ class UUIDFilter(Filter): class MultipleChoiceFilter(Filter): """ - This filter preforms OR(by default) or AND(using conjoined=True) query + This filter performs OR(by default) or AND(using conjoined=True) query on the selected options. - Advanced Use - ------------ + Advanced usage + -------------- Depending on your application logic, when all or no choices are selected, - filtering may be a noop. In this case you may wish to avoid the filtering - overhead, particularly if using a `distinct` call. + filtering may be a no-operation. In this case you may wish to avoid the + filtering overhead, particularly if using a `distinct` call. - Set `always_filter` to False after instantiation to enable the default - `is_noop` test. + You can override `get_filter_predicate` to use a custom filter. + By default it will use the filter's name for the key, and the value will + be the model object - or in case of passing in `to_field_name` the + value of that attribute on the model. - Override `is_noop` if you require a different test for your application. + Set `always_filter` to `False` after instantiation to enable the default + `is_noop` test. You can override `is_noop` if you need a different test + for your application. - `distinct` defaults to True on this class to preserve backward compatibility. + `distinct` defaults to `True` on this class to preserve backward + compatibility. """ field_class = forms.MultipleChoiceField always_filter = True def __init__(self, *args, **kwargs): - distinct = kwargs.get('distinct', True) - kwargs['distinct'] = distinct - - conjoined = kwargs.pop('conjoined', False) - self.conjoined = conjoined - + kwargs.setdefault('distinct', True) + self.conjoined = kwargs.pop('conjoined', False) super(MultipleChoiceFilter, self).__init__(*args, **kwargs) def is_noop(self, qs, value): """ - Return True to short-circuit unnecessary and potentially slow filtering. + Return `True` to short-circuit unnecessary and potentially slow + filtering. """ if self.always_filter: return False @@ -217,26 +250,32 @@ class MultipleChoiceFilter(Filter): return False def filter(self, qs, value): - value = value or () # Make sure we have an iterable + if not value: + # Even though not a noop, no point filtering if empty. + return qs if self.is_noop(qs, value): return qs - # Even though not a noop, no point filtering if empty - if not value: - return qs - - q = Q() + if not self.conjoined: + q = Q() for v in set(value): + predicate = self.get_filter_predicate(v) if self.conjoined: - qs = self.get_method(qs)(**{self.name: v}) + qs = self.get_method(qs)(**predicate) else: - q |= Q(**{self.name: v}) + q |= Q(**predicate) - if self.distinct: - return self.get_method(qs)(q).distinct() + if not self.conjoined: + qs = self.get_method(qs)(q) - return self.get_method(qs)(q) + return qs.distinct() if self.distinct else qs + + def get_filter_predicate(self, v): + try: + return {self.name: getattr(v, self.field.to_field_name)} + except (AttributeError, TypeError): + return {self.name: v} class DateFilter(Filter): @@ -453,11 +492,109 @@ class BaseRangeFilter(BaseCSVFilter): super(BaseRangeFilter, self).__init__(*args, **kwargs) +class OrderingFilter(BaseCSVFilter, ChoiceFilter): + """ + Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts + two additional arguments that are used to build the ordering choices. + + * ``fields`` is a mapping of {model field name: parameter name}. The + parameter names are exposed in the choices and mask/alias the field + names used in the ``order_by()`` call. Similar to field ``choices``, + ``fields`` accepts the 'list of two-tuples' syntax that retains order. + ``fields`` may also just be an iterable of strings. In this case, the + field names simply double as the exposed parameter names. + + * ``field_labels`` is an optional argument that allows you to customize + the display label for the corresponding parameter. It accepts a mapping + of {field name: human readable label}. Keep in mind that the key is the + field name, and not the exposed parameter name. + + Additionally, you can just provide your own ``choices`` if you require + explicit control over the exposed options. For example, when you might + want to disable descending sort options. + + This filter is also CSV-based, and accepts multiple ordering params. The + default select widget does not enable the use of this, but it is useful + for APIs. + + """ + descending_fmt = _('%s (descending)') + + def __init__(self, *args, **kwargs): + """ + ``fields`` may be either a mapping or an iterable. + ``field_labels`` must be a map of field names to display labels + """ + fields = kwargs.pop('fields', {}) + fields = self.normalize_fields(fields) + field_labels = kwargs.pop('field_labels', {}) + + self.param_map = {v: k for k, v in fields.items()} + + if 'choices' not in kwargs: + kwargs['choices'] = self.build_choices(fields, field_labels) + + kwargs.setdefault('label', _('Ordering')) + super(OrderingFilter, self).__init__(*args, **kwargs) + + def get_ordering_value(self, param): + descending = param.startswith('-') + param = param[1:] if descending else param + field_name = self.param_map.get(param, param) + + return "-%s" % field_name if descending else field_name + + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + ordering = [self.get_ordering_value(param) for param in value] + return qs.order_by(*ordering) + + @classmethod + def normalize_fields(cls, fields): + """ + Normalize the fields into an ordered map of {field name: param name} + """ + # fields is a mapping, copy into new OrderedDict + if isinstance(fields, dict): + return OrderedDict(fields) + + # convert iterable of values => iterable of pairs (field name, param name) + assert is_iterable(fields), \ + "'fields' must be an iterable (e.g., a list, tuple, or mapping)." + + # fields is an iterable of field names + assert all(isinstance(field, six.string_types) or + is_iterable(field) and len(field) == 2 # may need to be wrapped in parens + for field in fields), \ + "'fields' must contain strings or (field name, param name) pairs." + + return OrderedDict([ + (f, f) if isinstance(f, six.string_types) else f for f in fields + ]) + + def build_choices(self, fields, labels): + ascending = [ + (param, labels.get(field, pretty_name(param))) + for field, param in fields.items() + ] + descending = [ + ('-%s' % pair[0], self.descending_fmt % pair[1]) + for pair in ascending + ] + + # interleave the ascending and descending choices + return [val for pair in zip(ascending, descending) for val in pair] + + class MethodFilter(Filter): """ This filter will allow you to run a method that exists on the filterset class """ def __init__(self, *args, **kwargs): + deprecate('MethodFilter has been deprecated in favor of Filter.method') + # Get the action out of the kwargs action = kwargs.get('action', None) @@ -492,3 +629,43 @@ class MethodFilter(Filter): if parent_filter_method is not None: return parent_filter_method(qs, value) return qs + + +class FilterMethod(object): + """ + This helper is used to override Filter.filter() when a 'method' argument + is passed. It proxies the call to the actual method on the filter's parent. + """ + def __init__(self, filter_instance): + self.f = filter_instance + + def __call__(self, qs, value): + if value in EMPTY_VALUES: + return qs + + return self.method(qs, self.f.name, value) + + @property + def method(self): + """ + Resolve the method on the parent filterset. + """ + instance = self.f + + # noop if 'method' is a function + if callable(instance.method): + return instance.method + + # otherwise, method is the name of a method on the parent FilterSet. + assert hasattr(instance, 'parent'), \ + "Filter '%s' must have a parent FilterSet to find '.%s()'" % \ + (instance.name, instance.method) + + parent = instance.parent + method = getattr(parent, instance.method, None) + + assert callable(method), \ + "Expected parent FilterSet '%s.%s' to have a '.%s()' method." % \ + (parent.__class__.__module__, parent.__class__.__name__, instance.method) + + return method diff --git a/django_filters/filterset.py b/django_filters/filterset.py index e5f31ce..efae5c3 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -2,12 +2,10 @@ from __future__ import absolute_import from __future__ import unicode_literals import copy -import re from collections import OrderedDict from django import forms from django.forms.forms import NON_FIELD_ERRORS -from django.core.validators import EMPTY_VALUES from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.fields.related import ForeignObjectRel @@ -15,12 +13,12 @@ from django.utils import six from django.utils.text import capfirst from django.utils.translation import ugettext as _ -from .compat import remote_field, remote_model, remote_queryset +from .compat import remote_field, remote_queryset from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, - DurationFilter) -from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate + DurationFilter, OrderingFilter) +from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, pretty_name, deprecate ORDER_BY_FIELD = 'o' @@ -151,7 +149,18 @@ class FilterSetOptions(object): self.fields = getattr(options, 'fields', None) self.exclude = getattr(options, 'exclude', None) + self.filter_overrides = getattr(options, 'filter_overrides', {}) + + if hasattr(options, 'order_by'): + deprecate('Meta.order_by has been deprecated.', 1) + + if hasattr(options, 'order_by_field'): + deprecate('Meta.order_by_field has been deprecated.', 1) + self.order_by = getattr(options, 'order_by', False) + self.order_by_field = getattr(options, 'order_by_field', ORDER_BY_FIELD) + + self.strict = getattr(options, 'strict', STRICTNESS.RETURN_NO_RESULTS) self.form = getattr(options, 'form', forms.Form) @@ -175,6 +184,26 @@ class FilterSetMetaclass(type): opts = new_class._meta = FilterSetOptions( getattr(new_class, 'Meta', None)) + if hasattr(new_class, 'strict'): + deprecate('strict has been deprecated. Use Meta.strict instead.') + new_class._meta.strict = new_class.strict + + if hasattr(new_class, 'order_by_field'): + deprecate('order_by_field has been moved to the Meta class.') + new_class._meta.order_by_field = new_class.order_by_field + + if hasattr(new_class, 'filter_overrides'): + deprecate('filter_overrides has been moved to the Meta class.') + new_class._meta.filter_overrides = new_class.filter_overrides + + assert not hasattr(new_class, 'get_order_by'), \ + 'get_order_by() has been deprecated. Subclass OrderingFilter and override .filter() instead. ' \ + 'See: https://django-filter.readthedocs.io/en/latest/migration.html"' + + assert not hasattr(new_class, 'get_ordering_field'), \ + 'get_ordering_field() has been deprecated. Use OrderingFilter instead. ' \ + 'See: https://django-filter.readthedocs.io/en/latest/migration.html"' + # TODO: replace with deprecations # if opts.model and opts.fields: if opts.model: @@ -188,36 +217,40 @@ class FilterSetMetaclass(type): raise TypeError("Meta.fields contains a field that isn't defined " "on this FilterSet: {}".format(not_defined)) + # TODO: remove with deprecations + # check key existence instead of setdefault - prevents unnecessary filter construction + order_by_field = new_class._meta.order_by_field + if opts.order_by and order_by_field not in filters: + filters[order_by_field] = new_class.get_ordering_filter(opts, filters) + new_class.declared_filters = declared_filters new_class.base_filters = filters return new_class FILTER_FOR_DBFIELD_DEFAULTS = { - models.AutoField: { - 'filter_class': NumberFilter - }, - models.CharField: { - 'filter_class': CharFilter - }, - models.TextField: { - 'filter_class': CharFilter - }, - models.BooleanField: { - 'filter_class': BooleanFilter - }, - models.DateField: { - 'filter_class': DateFilter - }, - models.DateTimeField: { - 'filter_class': DateTimeFilter - }, - models.TimeField: { - 'filter_class': TimeFilter - }, - models.DurationField: { - 'filter_class': DurationFilter - }, + models.AutoField: {'filter_class': NumberFilter}, + models.CharField: {'filter_class': CharFilter}, + models.TextField: {'filter_class': CharFilter}, + models.BooleanField: {'filter_class': BooleanFilter}, + models.DateField: {'filter_class': DateFilter}, + models.DateTimeField: {'filter_class': DateTimeFilter}, + models.TimeField: {'filter_class': TimeFilter}, + models.DurationField: {'filter_class': DurationFilter}, + models.DecimalField: {'filter_class': NumberFilter}, + models.SmallIntegerField: {'filter_class': NumberFilter}, + models.IntegerField: {'filter_class': NumberFilter}, + models.PositiveIntegerField: {'filter_class': NumberFilter}, + models.PositiveSmallIntegerField: {'filter_class': NumberFilter}, + models.FloatField: {'filter_class': NumberFilter}, + models.NullBooleanField: {'filter_class': BooleanFilter}, + models.SlugField: {'filter_class': CharFilter}, + models.EmailField: {'filter_class': CharFilter}, + models.FilePathField: {'filter_class': CharFilter}, + models.URLField: {'filter_class': CharFilter}, + models.GenericIPAddressField: {'filter_class': CharFilter}, + models.CommaSeparatedIntegerField: {'filter_class': CharFilter}, + models.UUIDField: {'filter_class': UUIDFilter}, models.OneToOneField: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { @@ -238,56 +271,11 @@ FILTER_FOR_DBFIELD_DEFAULTS = { 'queryset': remote_queryset(f), } }, - models.DecimalField: { - 'filter_class': NumberFilter, - }, - models.SmallIntegerField: { - 'filter_class': NumberFilter, - }, - models.IntegerField: { - 'filter_class': NumberFilter, - }, - models.PositiveIntegerField: { - 'filter_class': NumberFilter, - }, - models.PositiveSmallIntegerField: { - 'filter_class': NumberFilter, - }, - models.FloatField: { - 'filter_class': NumberFilter, - }, - models.NullBooleanField: { - 'filter_class': BooleanFilter, - }, - models.SlugField: { - 'filter_class': CharFilter, - }, - models.EmailField: { - 'filter_class': CharFilter, - }, - models.FilePathField: { - 'filter_class': CharFilter, - }, - models.URLField: { - 'filter_class': CharFilter, - }, - models.GenericIPAddressField: { - 'filter_class': CharFilter, - }, - models.CommaSeparatedIntegerField: { - 'filter_class': CharFilter, - }, - models.UUIDField: { - 'filter_class': UUIDFilter, - }, } class BaseFilterSet(object): - filter_overrides = {} - order_by_field = ORDER_BY_FIELD - # What to do on on validation errors - strict = STRICTNESS.RETURN_NO_RESULTS + FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS def __init__(self, data=None, queryset=None, prefix=None, strict=None): self.is_bound = data is not None @@ -296,8 +284,9 @@ class BaseFilterSet(object): queryset = self._meta.model._default_manager.all() self.queryset = queryset self.form_prefix = prefix - if strict is not None: - self.strict = strict + + # What to do on on validation errors + self.strict = self._meta.strict if strict is None else strict self.filters = copy.deepcopy(self.base_filters) # propagate the model being used through the filters @@ -359,23 +348,6 @@ class BaseFilterSet(object): if value is not None: # valid & clean data qs = filter_.filter(qs, value) - if self._meta.order_by: - order_field = self.form.fields[self.order_by_field] - data = self.form[self.order_by_field].data - ordered_value = None - try: - ordered_value = order_field.clean(data) - except forms.ValidationError: - pass - - # With a None-queryset, ordering must be enforced (#84). - if (ordered_value in EMPTY_VALUES and - self.strict == STRICTNESS.RETURN_NO_RESULTS): - ordered_value = self.form.fields[self.order_by_field].choices[0][0] - - if ordered_value: - qs = qs.order_by(*self.get_order_by(ordered_value)) - self._qs = qs return self._qs @@ -386,7 +358,7 @@ class BaseFilterSet(object): fields = OrderedDict([ (name, filter_.field) for name, filter_ in six.iteritems(self.filters)]) - fields[self.order_by_field] = self.ordering_field + Form = type(str('%sForm' % self.__class__.__name__), (self._meta.form,), fields) if self._meta.together: @@ -397,49 +369,48 @@ class BaseFilterSet(object): self._form = Form(prefix=self.form_prefix) return self._form - def get_ordering_field(self): - if self._meta.order_by: - if isinstance(self._meta.order_by, (list, tuple)): - if isinstance(self._meta.order_by[0], (list, tuple)): - # e.g. (('field', 'Display name'), ...) - choices = [(f[0], f[1]) for f in self._meta.order_by] - else: - choices = [] - for f in self._meta.order_by: - if f[0] == '-': - label = _('%s (descending)' % capfirst(f[1:])) - else: - label = capfirst(f) - choices.append((f, label)) + @classmethod + def get_ordering_filter(cls, opts, filters): + assert not isinstance(opts.fields, dict), \ + "'order_by' is not compatible with the 'fields' dict syntax. Use OrderingFilter instead." + + def display_text(name, fltr): + """ + ``name`` is the filter's attribute name on the FilterSet + ``text`` is the current display text, which is either the ``name`` + or an explicitly assigned label. + """ + # TODO: use `fltr._label` in label-improvements branch + text = fltr.label or name.lstrip('-') + if name.startswith('-'): + text = _('%s (descending)' % text) + + return pretty_name(text) + + if isinstance(opts.order_by, (list, tuple)): + + # e.g. (('field', 'Display name'), ...) + if isinstance(opts.order_by[0], (list, tuple)): + choices = [(f[0], f[1]) for f in opts.order_by] + fields = {filters.get(f[0].lstrip('-')).name: f[0] for f in opts.order_by} + return OrderingFilter(choices=choices, fields=fields) + + # e.g. ('field1', 'field2', ...) else: - # add asc and desc field names - # use the filter's label if provided - choices = [] - for f, fltr in self.filters.items(): - choices.extend([ - (f, fltr.label or capfirst(f)), - ("-%s" % (f), _('%s (descending)' % (fltr.label or capfirst(f)))) - ]) - return forms.ChoiceField(label=_("Ordering"), required=False, - choices=choices) + # (filter name, filter instance) + order_by = [(f, filters.get(f.lstrip('-'))) for f in opts.order_by] - @property - def ordering_field(self): - if not hasattr(self, '_ordering_field'): - self._ordering_field = self.get_ordering_field() - return self._ordering_field + # preference filter label over attribute name + choices = [(f, display_text(f, fltr)) for f, fltr in order_by] + fields = {fltr.name: f for f, fltr in order_by} + return OrderingFilter(choices=choices, fields=fields) - def get_order_by(self, order_choice): - re_ordering_field = re.compile(r'(?P\-?)(?P.*)') - m = re.match(re_ordering_field, order_choice) - inverted = m.group('inverse') - filter_api_name = m.group('field') + # opts.order_by = True + order_by = filters.items() - _filter = self.filters.get(filter_api_name, None) - - if _filter and filter_api_name != _filter.name: - return [inverted + _filter.name] - return [order_choice] + fields = [(fltr.name, f) for f, fltr in order_by] + labels = {f: display_text(f, fltr) for f, fltr in order_by} + return OrderingFilter(fields=fields, field_labels=labels) @classmethod def filters_for_model(cls, model, opts): @@ -447,7 +418,7 @@ class BaseFilterSet(object): fields = opts.fields if fields is None: DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) - DEFAULTS.update(cls.filter_overrides) + DEFAULTS.update(opts.filter_overrides) fields = get_all_model_fields(model, field_types=DEFAULTS.keys()) return filters_for_model( @@ -493,8 +464,9 @@ class BaseFilterSet(object): @classmethod def filter_for_lookup(cls, f, lookup_type): - DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) - DEFAULTS.update(cls.filter_overrides) + DEFAULTS = dict(cls.FILTER_DEFAULTS) + if hasattr(cls, '_meta'): + DEFAULTS.update(cls._meta.filter_overrides) data = try_dbfield(DEFAULTS.get, f.__class__) or {} filter_class = data.get('filter_class') diff --git a/django_filters/rest_framework/__init__.py b/django_filters/rest_framework/__init__.py new file mode 100644 index 0000000..11af6d5 --- /dev/null +++ b/django_filters/rest_framework/__init__.py @@ -0,0 +1,5 @@ +# flake8: noqa +from __future__ import absolute_import +from .backends import DjangoFilterBackend +from .filterset import FilterSet +from .filters import * diff --git a/django_filters/rest_framework/backends.py b/django_filters/rest_framework/backends.py new file mode 100644 index 0000000..6655b44 --- /dev/null +++ b/django_filters/rest_framework/backends.py @@ -0,0 +1,64 @@ + +from __future__ import absolute_import + +from django.template import loader +from rest_framework.filters import BaseFilterBackend + +from .. import compat +from . import filterset + + +if compat.is_crispy: + filter_template = 'django_filters/rest_framework/crispy_form.html' +else: + filter_template = 'django_filters/rest_framework/form.html' + + +class DjangoFilterBackend(BaseFilterBackend): + default_filter_set = filterset.FilterSet + template = filter_template + + def get_filter_class(self, view, queryset=None): + """ + Return the django-filters `FilterSet` used to filter the queryset. + """ + filter_class = getattr(view, 'filter_class', None) + filter_fields = getattr(view, 'filter_fields', None) + + if filter_class: + filter_model = filter_class.Meta.model + + assert issubclass(queryset.model, filter_model), \ + 'FilterSet model %s does not match queryset model %s' % \ + (filter_model, queryset.model) + + return filter_class + + if filter_fields: + class AutoFilterSet(self.default_filter_set): + class Meta: + model = queryset.model + fields = filter_fields + + return AutoFilterSet + + return None + + def filter_queryset(self, request, queryset, view): + filter_class = self.get_filter_class(view, queryset) + + if filter_class: + return filter_class(request.query_params, queryset=queryset).qs + + return queryset + + def to_html(self, request, queryset, view): + filter_class = self.get_filter_class(view, queryset) + if not filter_class: + return None + filter_instance = filter_class(request.query_params, queryset=queryset) + context = { + 'filter': filter_instance + } + template = loader.get_template(self.template) + return compat.template_render(template, context) diff --git a/django_filters/rest_framework/filters.py b/django_filters/rest_framework/filters.py new file mode 100644 index 0000000..5ebae48 --- /dev/null +++ b/django_filters/rest_framework/filters.py @@ -0,0 +1,10 @@ + +from ..filters import * +from ..widgets import BooleanWidget + + +class BooleanFilter(BooleanFilter): + def __init__(self, *args, **kwargs): + kwargs.setdefault('widget', BooleanWidget) + + super(BooleanFilter, self).__init__(*args, **kwargs) diff --git a/django_filters/rest_framework/filterset.py b/django_filters/rest_framework/filterset.py new file mode 100644 index 0000000..c28c114 --- /dev/null +++ b/django_filters/rest_framework/filterset.py @@ -0,0 +1,39 @@ + +from __future__ import absolute_import +from copy import deepcopy + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from django_filters import filterset +from .filters import BooleanFilter, IsoDateTimeFilter +from .. import compat + +if compat.is_crispy: + from crispy_forms.helper import FormHelper + from crispy_forms.layout import Layout, Submit + + +FILTER_FOR_DBFIELD_DEFAULTS = deepcopy(filterset.FILTER_FOR_DBFIELD_DEFAULTS) +FILTER_FOR_DBFIELD_DEFAULTS.update({ + models.DateTimeField: {'filter_class': IsoDateTimeFilter}, + models.BooleanField: {'filter_class': BooleanFilter}, +}) + + +class FilterSet(filterset.FilterSet): + FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS + + def __init__(self, *args, **kwargs): + super(FilterSet, self).__init__(*args, **kwargs) + + if compat.is_crispy: + layout_components = list(self.form.fields.keys()) + [ + Submit('', _('Submit'), css_class='btn-default'), + ] + helper = FormHelper() + helper.form_method = 'GET' + helper.template_pack = 'bootstrap3' + helper.layout = Layout(*layout_components) + + self.form.helper = helper diff --git a/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html b/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html new file mode 100644 index 0000000..171767c --- /dev/null +++ b/django_filters/rest_framework/templates/django_filters/rest_framework/crispy_form.html @@ -0,0 +1,5 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +

{% trans "Field filters" %}

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

{% trans "Field filters" %}

+
+ {{ filter.form.as_p }} + +
diff --git a/django_filters/utils.py b/django_filters/utils.py index 510d91c..8212599 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -9,6 +9,11 @@ from django.db.models.fields import FieldDoesNotExist from django.db.models.fields.related import ForeignObjectRel from django.utils import six, timezone +try: + from django.forms.utils import pretty_name +except ImportError: # Django 1.8 + from django.forms.forms import pretty_name + from .compat import remote_field, remote_model from .exceptions import FieldLookupError diff --git a/django_filters/widgets.py b/django_filters/widgets.py index fc14c04..f910a80 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -16,6 +16,8 @@ from django.utils.safestring import mark_safe from django.utils.six import string_types from django.utils.translation import ugettext as _ +from .compat import format_value + class LinkWidget(forms.Widget): def __init__(self, attrs=None, choices=()): @@ -68,9 +70,9 @@ class LinkWidget(forms.Widget): except AttributeError: url = urlencode(data) return self.option_string() % { - 'attrs': selected and ' class="selected"' or '', - 'query_string': url, - 'label': force_text(option_label) + 'attrs': selected and ' class="selected"' or '', + 'query_string': url, + 'label': force_text(option_label) } def option_string(self): @@ -151,7 +153,7 @@ class CSVWidget(forms.TextInput): def render(self, name, value, attrs=None): if self._isiterable(value): - value = [force_text(self._format_value(v)) for v in value] + value = [force_text(format_value(self, v)) for v in value] value = ','.join(list(value)) return super(CSVWidget, self).render(name, value, attrs) diff --git a/docs/conf.py b/docs/conf.py index 2b8e198..d795de3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.' # built documents. # # The short X.Y version. -version = '0.14.0' +version = '0.15.0' # The full version, including alpha/beta/rc tags. -release = '0.14.0' +release = '0.15.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/img/form.png b/docs/img/form.png new file mode 100644 index 0000000..5baef32 Binary files /dev/null and b/docs/img/form.png differ diff --git a/docs/index.txt b/docs/index.txt index 0f00a70..5d0c5c7 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -14,6 +14,7 @@ Contents: install usage + rest_framework ref/filterset ref/filters ref/fields diff --git a/docs/install.txt b/docs/install.txt index 6265241..477d062 100644 --- a/docs/install.txt +++ b/docs/install.txt @@ -3,6 +3,8 @@ Installing django-filter Install with pip: +.. code-block:: bash + pip install django-filter And then add ``'django_filters'`` to your ``INSTALLED_APPS``. diff --git a/docs/migration.txt b/docs/migration.txt index 5f7afb7..aa2f8ec 100644 --- a/docs/migration.txt +++ b/docs/migration.txt @@ -8,6 +8,42 @@ release has also been created to help with migration. It is compatible with both the existing and new APIs and will raise warnings for deprecated behavior. +MethodFilter and Filter.action replaced by Filter.method +-------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/382 + +The functionality of ``MethodFilter`` and ``Filter.action`` has been merged +together and replaced by the ``Filter.method`` parameter. The ``method`` +parameter takes either a callable or the name of a ``FilterSet`` method. The +signature now takes an additional ``name`` argument that is the name of the +model field to be filtered on. + +Since ``method`` is now a parameter of all filters, inputs are validated and +cleaned by its ``field_class``. The function will receive the cleaned value +instead of the raw value. + +.. code-block:: python + + # 0.x + class UserFilter(FilterSet): + last_login = filters.MethodFilter() + + def filter_last_login(self, qs, value): + # try to convert value to datetime, which may fail. + if value and looks_like_a_date(value): + value = datetime(value) + + return qs.filter(last_login=value}) + + + # 1.0 + class UserFilter(FilterSet): + last_login = filters.CharFilter(method='filter_last_login') + + def filter_last_login(self, qs, name, value): + return qs.filter(**{name: value}) + + QuerySet methods are no longer proxied -------------------------------------- Details: https://github.com/carltongibson/django-filter/pull/440 @@ -52,3 +88,78 @@ the ``Meta.exclude`` attribute. class Meta: model = User exclude = ['password'] + + +Move FilterSet options to Meta class +------------------------------------ +Details: https://github.com/carltongibson/django-filter/issues/430 + +Several ``FilterSet`` options have been moved to the ``Meta`` class to prevent +potential conflicts with declared filter names. This includes: + +* ``filter_overrides`` +* ``strict`` +* ``order_by_field`` + +.. code-block:: python + + # 0.x + class UserFilter(FilterSet): + filter_overrides = {} + strict = STRICTNESS.RAISE_VALIDATION_ERROR + order_by_field = 'order' + ... + + # 1.0 + class UserFilter(FilterSet): + ... + + class Meta: + filter_overrides = {} + strict = STRICTNESS.RAISE_VALIDATION_ERROR + order_by_field = 'order' + + +FilterSet ordering replaced by OrderingFilter +--------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/472 + +The FilterSet ordering options and methods have been deprecated and replaced +by :ref:`OrderingFilter `. Deprecated options include: + + +* ``Meta.order_by`` +* ``Meta.order_by_field`` + +These options retain backwards compatibility with the following caveats: + +* ``order_by`` asserts that ``Meta.fields`` is not using the dict syntax. This + previously was undefined behavior, however the migration code is unable to + support it. +* Prior, if no ordering was specified in the request, the FilterSet implicitly + filtered by the first param in the ``order_by`` option. This behavior cannot + be easily emulated but can be fixed by ensuring that the passed in queryset + explicitly calls ``.order_by()``. + + .. code-block:: python + + filterset = MyFilterSet(queryset=MyModel.objects.order_by('field')) + +The following methods are deprecated and will raise an assertion if present +on the FilterSet: + +* ``.get_order_by()`` +* ``.get_ordering_field()`` + +To fix this, simply remove the methods from your class. You can subclass +``OrderingFilter`` to migrate any custom logic. + + +Deprecated ``FILTERS_HELP_TEXT_FILTER`` and ``FILTERS_HELP_TEXT_EXCLUDE`` +------------------------------------------------------------------------- +Details: https://github.com/carltongibson/django-filter/pull/437 + +Generated filter labels in 1.0 will be more descriptive, including humanized +text about the lookup being performed and if the filter is an exclusion filter. + +These settings will no longer have an effect and will be removed in the 1.0 release. diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index aabe031..1f8ad2e 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -43,13 +43,51 @@ additional ones that django-filter provides which may be useful: * :ref:`RangeWidget ` -- this widget is used with ``RangeFilter`` to generate two form input elements using a single field. -``action`` +.. _filter-method: + +``method`` ~~~~~~~~~~ -An optional callable that tells the filter how to handle the queryset. It -recieves a ``QuerySet`` and the value to filter on and should return a -``Queryset`` that is filtered appropriately. `action` will default to -``filter_{value-of-name-attribute}`` +An optional argument that tells the filter how to handle the queryset. It can +accept either a callable or the name of a method on the ``FilterSet``. The +method receives a ``QuerySet``, the name of the model field to filter on, and +the value to filter with. It should return a ``Queryset`` that is filtered +appropriately. + +The passed in value is validated and cleaned by the filter's ``field_class``, +so raw value transformation and empty value checking should be unnecessary. + +.. code-block:: python + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method='filter_published') + + def filter_published(self, queryset, name, value): + # construct the full lookup expression. + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + # alternatively, it may not be necessary to construct the lookup. + return queryset.filter(published_on__isnull=False) + + class Meta: + model = Book + fields = ['published'] + + + # Callables may also be defined out of the class scope. + def filter_not_empty(queryset, name, value): + lookup = '__'.join([name, 'isnull']) + return queryset.filter(**{lookup: False}) + + class F(FilterSet): + """Filter for Books by if books are published or not""" + published = BooleanFilter(name='published_on', method=filter_not_empty) + + class Meta: + model = Book + fields = ['published'] ``lookup_expr`` ~~~~~~~~~~~~~~~ @@ -114,13 +152,21 @@ Any additional keyword arguments are stored as the ``extra`` parameter on the fi ModelChoiceFilter and ModelMultipleChoiceFilter arguments --------------------------------------------------------- -These arguments apply specifically to ModelChoiceFiler and +These arguments apply specifically to ModelChoiceFilter and ModelMultipleChoiceFilter only. ``queryset`` ~~~~~~~~~~~~ -``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg. +``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to +operate on which must be passed as a kwarg. + +``to_field_name`` +~~~~~~~~~~~~~~~~~ + +If you pass in ``to_field_name`` (which gets forwarded to the Django field), +it will be used also in the default ``get_filter_predicate`` implementation +as the model's attribute. Filters @@ -236,7 +282,7 @@ Matches on a date and time. Used with ``DateTimeField`` by default. Uses ``IsoDateTimeField`` to support filtering on ISO 8601 formatted dates, as are often used in APIs, and are employed by default by Django REST Framework. -Example. :: +Example:: class F(FilterSet): """Filter for Books by date published, using ISO 8601 formatted dates""" @@ -263,10 +309,11 @@ and week designators, e.g. 'P3DT10H22M'). Similar to a ``ChoiceFilter`` except it works with related models, used for ``ForeignKey`` by default. -If automatically instantiated ``ModelChoiceFilter`` will use the default ``QuerySet`` for the -related field. If manually instantiated you **must** provide the ``queryset`` kwarg. +If automatically instantiated, ``ModelChoiceFilter`` will use the default +``QuerySet`` for the related field. If manually instantiated you **must** +provide the ``queryset`` kwarg. -Example. :: +Example:: class F(FilterSet): """Filter for books by author""" @@ -282,9 +329,55 @@ Example. :: Similar to a ``MultipleChoiceFilter`` except it works with related models, used for ``ManyToManyField`` by default. -As with ``ModelChoiceFilter``, if automatically instantiated ``ModelMultipleChoiceFilter`` will use -the default ``QuerySet`` for the related field. If manually instantiated you **must** provide the -``queryset`` kwarg. +As with ``ModelChoiceFilter``, if automatically instantiated, +``ModelMultipleChoiceFilter`` will use the default ``QuerySet`` for the related +field. If manually instantiated you **must** provide the ``queryset`` kwarg. + +To use a custom field name for the lookup, you can use ``to_field_name``:: + + class FooFilter(BaseFilterSet): + foo = django_filters.filters.ModelMultipleChoiceFilter( + name='attr__uuid', + to_field_name='uuid', + queryset=Foo.objects.all(), + ) + +If you want to use a custom queryset, e.g. to add annotated fields, this can be +done as follows:: + + class MyMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): + def get_filter_predicate(self, v): + return {'annotated_field': v.annotated_field} + + def filter(self, qs, value): + if value: + qs = qs.annotate_with_custom_field() + qs = super().filter(qs, value) + return qs + + foo = MyMultipleChoiceFilter( + to_field_name='annotated_field', + queryset=Model.objects.annotate_with_custom_field(), + ) + +The ``annotate_with_custom_field`` method would be defined through a custom +QuerySet, which then gets used as the model's manager:: + + class CustomQuerySet(models.QuerySet): + def annotate_with_custom_field(self): + return self.annotate( + custom_field=Case( + When(foo__isnull=False, + then=F('foo__uuid')), + When(bar__isnull=False, + then=F('bar__uuid')), + default=None, + ), + ) + + class MyModel(models.Model): + objects = CustomQuerySet.as_manager() + ``NumberFilter`` ~~~~~~~~~~~~~~~~ @@ -478,7 +571,7 @@ this filter class is used in conjunction with another filter class, as this class **only** validates that the incoming value is comma-separated. The secondary filter is then used to validate the individual values. -Example. :: +Example:: class NumberInFilter(BaseInFilter, NumericFilter): pass @@ -505,7 +598,7 @@ This is a base class used for creating RANGE lookup filters. It behaves identically to ``BaseInFilter`` with the exception that it expects only two comma-separated values. -Example. :: +Example:: class NumberRangeFilter(BaseInFilter, NumericFilter): pass @@ -533,7 +626,7 @@ This is a ``Filter`` that will allow you to run a method that exists on the filt this filter is a property of. Set the `action` to a string that will map to a method on the filter set class. `action` will default to ``filter_{value-of-name-attribute}`` -Example. :: +Example:: class F(FilterSet): """Filter for Books by if books are published or not""" @@ -547,3 +640,81 @@ Example. :: class Meta: model = Book fields = ['published'] + + +.. _ordering-filter: + +``OrderingFilter`` +~~~~~~~~~~~~~~~~~~ + +Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts +two additional arguments that are used to build the ordering choices. + +* ``fields`` is a mapping of {model field name: parameter name}. The + parameter names are exposed in the choices and mask/alias the field + names used in the ``order_by()`` call. Similar to field ``choices``, + ``fields`` accepts the 'list of two-tuples' syntax that retains order. + ``fields`` may also just be an iterable of strings. In this case, the + field names simply double as the exposed parameter names. + +* ``field_labels`` is an optional argument that allows you to customize + the display label for the corresponding parameter. It accepts a mapping + of {field name: human readable label}. Keep in mind that the key is the + field name, and not the exposed parameter name. + +.. code-block:: python + + class UserFilter(FilterSet): + account = CharFilter(name='username') + status = NumberFilter(name='status') + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ('username', 'account'), + ('first_name', 'first_name'), + ('last_name', 'last_name'), + }, + + # labels do not need to retain order + field_labels={ + 'username': 'User account', + } + ) + + class Meta: + model = User + fields = ['first_name', 'last_name'] + + >>> UserFilter().filter['o'].field.choices + [ + ('account', 'User account'), + ('-account', 'User account (descending)'), + ('first_name', 'First name'), + ('-first_name', 'First name (descending)'), + ('last_name', 'Last name'), + ('-last_name', 'Last name (descending)'), + ] + +Additionally, you can just provide your own ``choices`` if you require +explicit control over the exposed options. For example, when you might +want to disable descending sort options. + +.. code-block:: python + + class UserFilter(FilterSet): + account = CharFilter(name='username') + status = NumberFilter(name='status') + + o = OrderingFilter( + choices=( + ('account', 'Account'), + ), + fields={ + 'username': 'account', + }, + ) + +This filter is also CSV-based, and accepts multiple ordering params. The +default select widget does not enable the use of this, but it is useful +for APIs. diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 760442d..be84085 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -16,9 +16,13 @@ Default: ``True`` Enable / disable the default field ``help_text`` on filters where ``exclude=True`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. +.. note:: This setting is pending deprecation. + FILTERS_HELP_TEXT_FILTER ------------------------ Default: ``True`` Enable / disable the default field ``help_text`` on filters where ``exclude=False`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. + +.. note:: This setting is pending deprecation. diff --git a/docs/rest_framework.txt b/docs/rest_framework.txt new file mode 100644 index 0000000..53c8c0d --- /dev/null +++ b/docs/rest_framework.txt @@ -0,0 +1,111 @@ +Django Rest Framework +===================== + +Integration with `Django Rest Framework`__ is provided through a DRF-specific ``FilterSet`` and a `filter backend`__. These may be found in the ``rest_framework`` sub-package. + +__ http://www.django-rest-framework.org/ +__ http://www.django-rest-framework.org/api-guide/filtering/ + + +Quickstart +---------- + +Using the new ``FilterSet`` simply requires changing the import path. Instead of importing from ``django_filters``, import from the ``rest_framework`` sub-package. + +.. code-block:: python + + from django_filters import rest_framework as filters + + class ProductFilter(filters.FilterSet): + ... + +Your view class will also need to add ``DjangoFilterBackend`` to the ``filter_backends``. + +.. code-block:: python + + from django_filters import rest_framework as filters + + class ProductList(generics.ListAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer + filter_backends = (filters.DjangoFilterBackend,) + filter_fields = ('category', 'in_stock') + +If you want to use the django-filter backend by default, add it to the ``DEFAULT_FILTER_BACKENDS`` setting. + +.. code-block:: python + + # settings.py + REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ( + 'django_filters.rest_framework.DjangoFilterBackend', + ... + ), + } + + +Adding a FilterSet with ``filter_class`` +---------------------------------------- + +To enable filtering with a ``FilterSet``, add it to the ``filter_class`` parameter on your view class. + +.. code-block:: python + + from rest_framework import generics + from django_filters import rest_framework as filters + from myapp import Product + + + class ProductFilter(filters.FilterSet): + min_price = django_filters.NumberFilter(name="price", lookup_expr='gte') + max_price = django_filters.NumberFilter(name="price", lookup_expr='lte') + + class Meta: + model = Product + fields = ['category', 'in_stock', 'min_price', 'max_price'] + + + class ProductList(generics.ListAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer + filter_backends = (filters.DjangoFilterBackend,) + filter_class = ProductFilter + + +Specifying ``filter_fields`` +---------------------------- + +You may bypass creating a ``FilterSet`` by instead adding ``filter_fields`` to your view class. This is equivalent to creating a FilterSet with just :ref:`Meta.fields `. + + +.. code-block:: python + + from rest_framework import generics + from django_filters import rest_framework as filters + from myapp import Product + + + class ProductList(generics.ListAPIView): + queryset = Product.objects.all() + filter_backends = (filters.DjangoFilterBackend,) + filter_fields = ('category', 'in_stock') + + + # Equivalent FilterSet: + class ProductFilter(filters.FilterSet): + class Meta: + model = Product + fields = ('category', 'in_stock') + + +Crispy Forms +------------ +If you are using DRF's browsable API or admin API you may also want to install `django-crispy-forms`, which will enhance the presentation of the filter forms in HTML views, by allowing them to render Bootstrap 3 HTML. Note that this isn't actively supported, although pull requests for bug fixes are welcome. + +.. code-block:: bash + + pip install django-crispy-forms + +With crispy forms installed and added to Django's `INSTALLED_APPS`, the browsable API will present a filtering control for `DjangoFilterBackend`, like so: + +.. image:: img/form.png diff --git a/docs/tests.txt b/docs/tests.txt index 1a42f47..fa22bf6 100644 --- a/docs/tests.txt +++ b/docs/tests.txt @@ -18,6 +18,8 @@ Set up a virtualenv for the test suite Run the following to create a new virtualenv to run the test suite in:: +.. code-block:: bash + virtualenv django-filter-tests cd django-filter-tests . bin/activate @@ -27,10 +29,14 @@ Get a copy of django-filter Get the django-filter source code using the following command:: +.. code-block:: bash + git clone https://github.com/alex/django-filter.git Switch to the django-filter directory:: +.. code-block:: bash + cd django-filter Install the test dependencies @@ -39,10 +45,14 @@ Install the test dependencies Run the following to install the test dependencies within the virutalenv:: +.. code-block:: bash + pip install -r requirements/test.txt Run the django-filter tests:: +.. code-block:: bash + python runtests.py @@ -52,5 +62,7 @@ Testing all supported versions You can also use the excellent tox testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run:: +.. code-block:: bash + tox diff --git a/docs/usage.txt b/docs/usage.txt index 1abfe9c..3b5d8a3 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -236,71 +236,25 @@ default filters for all the models fields of the same kind using } -Custom filtering with MethodFilter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Customize filtering with ``Filter.method`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you want fine control over each individual filter attribute, you can use -the ``MethodFilter`` filter. +You can control the behavior of a filter by specifying a ``method`` to perform +filtering. View more information in the :ref:`method reference `. -By passing in the name of a custom defined filter function as an ``action``, -the filter attribute gets linked to the custom filter function. -Here is an example of overriding the filter function of the -filter attribute ``username`` -:: +.. code-block:: python class F(django_filters.FilterSet): - username = MethodFilter(action='my_custom_filter') + username = CharFilter(method='my_custom_filter') - class Meta: - model = User - fields = ['username'] + class Meta: + model = User + fields = ['username'] - def my_custom_filter(self, queryset, value): - return queryset.filter( - username=value - ) - - -The filter function can also be defined outside of the filter class scope. -Though you would need to pass in the actual function value, not it's name. -:: - - def my_custom_filter(queryset, value): - return queryset.filter( - username=value - ) - - class F(django_filters.FilterSet): - # Notice: In this case, action accepts a func, not a string - username = MethodFilter(action=my_custom_filter) - - class Meta: - model = User - fields = ['username'] - - -Lastly, when using a ``MethodFilter``, there is no need to define an action. -You may simply do the following and ``filter_username`` will be auto-detected -and used. :: - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - -Under the hood, if ``action`` is not defined, ``django_filter`` -searches for a class method with a name that follows the pattern -``filter_{{ATTRIBUTE_NAME}}``. For example, if the attribute name is -``email``, then the filter class will be scanned for the filter function -``filter_email``. If no action is provided, and no filter class -function is found, then the filter attribute will be left unfiltered. + def my_custom_filter(self, queryset, name, value): + return queryset.filter(**{ + name: value, + }) The view diff --git a/requirements/maintainer.txt b/requirements/maintainer.txt index f7cdb98..63f5935 100644 --- a/requirements/maintainer.txt +++ b/requirements/maintainer.txt @@ -13,13 +13,14 @@ pathtools==0.1.2 pbr==1.7.0 pkginfo==1.2.1 Pygments==2.1.3 -pytz==2015.7 +pytz==2016.6.1 PyYAML==3.11 requests==2.9.1 requests-toolbelt==0.6.0 six==1.9.0 snowballstemmer==1.2.1 Sphinx==1.3.6 +sphinx-autobuild==0.6.0 sphinx-rtd-theme==0.1.9 tornado==4.2.1 twine==1.6.5 diff --git a/requirements/test-ci.txt b/requirements/test-ci.txt new file mode 100644 index 0000000..85ceb08 --- /dev/null +++ b/requirements/test-ci.txt @@ -0,0 +1,3 @@ +coverage +mock +pytz diff --git a/requirements/test.txt b/requirements/test.txt index a5d50f1..5b885fa 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,3 @@ --r maintainer.txt -coverage==3.7.1 -django-discover-runner -mock -sphinx-autobuild +-r travis-ci.txt +django +djangorestframework diff --git a/requirements/travis-ci.txt b/requirements/travis-ci.txt deleted file mode 100644 index a43a1b7..0000000 --- a/requirements/travis-ci.txt +++ /dev/null @@ -1 +0,0 @@ --r test.txt diff --git a/setup.py b/setup.py index 3f9264a..ab93370 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ f = open('README.rst') readme = f.read() f.close() -version = '0.14.0' +version = '0.15.0' if sys.argv[-1] == 'publish': if os.system("pip freeze | grep wheel"): @@ -34,7 +34,7 @@ setup( maintainer_email='carlton.gibson@noumenal.es', url='http://github.com/carltongibson/django-filter/tree/master', packages=find_packages(exclude=['tests']), - package_data = { + package_data={ 'django_filters': [ 'locale/*/LC_MESSAGES/*', ], diff --git a/tests/rest_framework/__init__.py b/tests/rest_framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rest_framework/models.py b/tests/rest_framework/models.py new file mode 100644 index 0000000..4be18b1 --- /dev/null +++ b/tests/rest_framework/models.py @@ -0,0 +1,28 @@ + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class BasicModel(models.Model): + text = models.CharField( + max_length=100, + verbose_name=_("Text comes here"), + help_text=_("Text description.") + ) + + +class BaseFilterableItem(models.Model): + text = models.CharField(max_length=100) + + +class FilterableItem(BaseFilterableItem): + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + +class DjangoFilterOrderingModel(models.Model): + date = models.DateField() + text = models.CharField(max_length=10) + + class Meta: + ordering = ['-date'] diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py new file mode 100644 index 0000000..2a569ab --- /dev/null +++ b/tests/rest_framework/test_backends.py @@ -0,0 +1,378 @@ +from __future__ import unicode_literals + +import datetime +from decimal import Decimal + +from django.conf.urls import url +from django.test import TestCase +from django.test.utils import override_settings +from django.utils.dateparse import parse_date + +try: + from django.urls import reverse +except ImportError: + # Django < 1.10 compatibility + from django.core.urlresolvers import reverse + +from rest_framework import generics, serializers, status +from rest_framework.test import APIRequestFactory + +from django_filters import filters +from django_filters.rest_framework import DjangoFilterBackend, FilterSet + +from .models import BaseFilterableItem, BasicModel, FilterableItem, DjangoFilterOrderingModel + +factory = APIRequestFactory() + + +class FilterableItemSerializer(serializers.ModelSerializer): + class Meta: + model = FilterableItem + fields = '__all__' + + +# Basic filter on a list view. +class FilterFieldsRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_fields = ['decimal', 'date'] + filter_backends = (DjangoFilterBackend,) + + +# These class are used to test a filter class. +class SeveralFieldsFilter(FilterSet): + text = filters.CharFilter(lookup_expr='icontains') + decimal = filters.NumberFilter(lookup_expr='lt') + date = filters.DateFilter(lookup_expr='gt') + + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterClassRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + +# These classes are used to test a misconfigured filter class. +class MisconfiguredFilter(FilterSet): + text = filters.CharFilter(lookup_expr='icontains') + + class Meta: + model = BasicModel + fields = ['text'] + + +class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = MisconfiguredFilter + filter_backends = (DjangoFilterBackend,) + + +class FilterClassDetailView(generics.RetrieveAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + +# These classes are used to test base model filter support +class BaseFilterableItemFilter(FilterSet): + text = filters.CharFilter() + + class Meta: + model = BaseFilterableItem + fields = '__all__' + + +class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_class = BaseFilterableItemFilter + filter_backends = (DjangoFilterBackend,) + + +# Regression test for #814 +class FilterFieldsQuerysetView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_fields = ['decimal', 'date'] + filter_backends = (DjangoFilterBackend,) + + +class GetQuerysetView(generics.ListCreateAPIView): + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backends = (DjangoFilterBackend,) + + def get_queryset(self): + return FilterableItem.objects.all() + + +urlpatterns = [ + url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), + url(r'^$', FilterClassRootView.as_view(), name='root-view'), + url(r'^get-queryset/$', GetQuerysetView.as_view(), name='get-queryset-view'), +] + + +class CommonFilteringTestCase(TestCase): + def _serialize_object(self, obj): + return {'id': obj.id, 'text': obj.text, 'decimal': str(obj.decimal), 'date': obj.date.isoformat()} + + def setUp(self): + """ + Create 10 FilterableItem instances. + """ + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) + for i in range(10): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + self._serialize_object(obj) + for obj in self.objects.all() + ] + + +class IntegrationTestFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered list views. + """ + + def test_get_filtered_fields_root_view(self): + """ + GET requests to paginated ListCreateAPIView should return paginated results. + """ + view = FilterFieldsRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) == search_decimal] + self.assertEqual(response.data, expected_data) + + # Tests that the date filter works. + search_date = datetime.date(2012, 9, 22) + request = factory.get('/', {'date': '%s' % search_date}) # search_date str: '2012-09-22' + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) == search_date] + self.assertEqual(response.data, expected_data) + + def test_filter_with_queryset(self): + """ + Regression test for #814. + """ + view = FilterFieldsQuerysetView.as_view() + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) == search_decimal] + self.assertEqual(response.data, expected_data) + + def test_filter_with_get_queryset_only(self): + """ + Regression test for #834. + """ + view = GetQuerysetView.as_view() + request = factory.get('/get-queryset/') + view(request).render() + # Used to raise "issubclass() arg 2 must be a class or tuple of classes" + # here when neither `model' nor `queryset' was specified. + + def test_get_filtered_class_root_view(self): + """ + GET requests to filtered ListCreateAPIView that have a filter_class set + should return filtered results. + """ + view = FilterClassRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter set with 'lt' in the filter class works. + search_decimal = Decimal('4.25') + request = factory.get('/', {'decimal': '%s' % search_decimal}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if Decimal(f['decimal']) < search_decimal] + self.assertEqual(response.data, expected_data) + + # Tests that the date filter set with 'gt' in the filter class works. + search_date = datetime.date(2012, 10, 2) + request = factory.get('/', {'date': '%s' % search_date}) # search_date str: '2012-10-02' + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) > search_date] + self.assertEqual(response.data, expected_data) + + # Tests that the text filter set with 'icontains' in the filter class works. + search_text = 'ff' + request = factory.get('/', {'text': '%s' % search_text}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if search_text in f['text'].lower()] + self.assertEqual(response.data, expected_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + request = factory.get('/', { + 'decimal': '%s' % (search_decimal,), + 'date': '%s' % (search_date,) + }) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if parse_date(f['date']) > search_date and + Decimal(f['decimal']) < search_decimal] + self.assertEqual(response.data, expected_data) + + def test_incorrectly_configured_filter(self): + """ + An error should be displayed when the filter class is misconfigured. + """ + view = IncorrectlyConfiguredRootView.as_view() + + request = factory.get('/') + self.assertRaises(AssertionError, view, request) + + def test_base_model_filter(self): + """ + The `get_filter_class` model checks should allow base model filters. + """ + view = BaseFilterableItemFilterRootView.as_view() + + request = factory.get('/?text=aaa') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_unknown_filter(self): + """ + GET requests with filters that aren't configured should return 200. + """ + view = FilterFieldsRootView.as_view() + + search_integer = 10 + request = factory.get('/', {'integer': '%s' % search_integer}) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +@override_settings(ROOT_URLCONF='tests.rest_framework.test_backends') +class IntegrationTestDetailFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered detail views. + """ + def _get_url(self, item): + return reverse('detail-view', kwargs=dict(pk=item.pk)) + + def test_get_filtered_detail_view(self): + """ + GET requests to filtered RetrieveAPIView that have a filter_class set + should return filtered results. + """ + item = self.objects.all()[0] + data = self._serialize_object(item) + + # Basic test with no filter. + response = self.client.get(self._get_url(item)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, data) + + # Tests that the decimal filter set that should fail. + search_decimal = Decimal('4.25') + high_item = self.objects.filter(decimal__gt=search_decimal)[0] + response = self.client.get( + '{url}'.format(url=self._get_url(high_item)), + {'decimal': '{param}'.format(param=search_decimal)}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Tests that the decimal filter set that should succeed. + search_decimal = Decimal('4.25') + low_item = self.objects.filter(decimal__lt=search_decimal)[0] + low_item_data = self._serialize_object(low_item) + response = self.client.get( + '{url}'.format(url=self._get_url(low_item)), + {'decimal': '{param}'.format(param=search_decimal)}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, low_item_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + valid_item = self.objects.filter(decimal__lt=search_decimal, date__gt=search_date)[0] + valid_item_data = self._serialize_object(valid_item) + response = self.client.get( + '{url}'.format(url=self._get_url(valid_item)), { + 'decimal': '{decimal}'.format(decimal=search_decimal), + 'date': '{date}'.format(date=search_date) + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, valid_item_data) + + +class DjangoFilterOrderingSerializer(serializers.ModelSerializer): + class Meta: + model = DjangoFilterOrderingModel + fields = '__all__' + + +class DjangoFilterOrderingTests(TestCase): + def setUp(self): + data = [{ + 'date': datetime.date(2012, 10, 8), + 'text': 'abc' + }, { + 'date': datetime.date(2013, 10, 8), + 'text': 'bcd' + }, { + 'date': datetime.date(2014, 10, 8), + 'text': 'cde' + }] + + for d in data: + DjangoFilterOrderingModel.objects.create(**d) + + def test_default_ordering(self): + class DjangoFilterOrderingView(generics.ListAPIView): + serializer_class = DjangoFilterOrderingSerializer + queryset = DjangoFilterOrderingModel.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ['text'] + ordering = ('-date',) + + view = DjangoFilterOrderingView.as_view() + request = factory.get('/') + response = view(request) + + self.assertEqual( + response.data, + [ + {'id': 3, 'date': '2014-10-08', 'text': 'cde'}, + {'id': 2, 'date': '2013-10-08', 'text': 'bcd'}, + {'id': 1, 'date': '2012-10-08', 'text': 'abc'} + ] + ) diff --git a/tests/rest_framework/test_filters.py b/tests/rest_framework/test_filters.py new file mode 100644 index 0000000..872094e --- /dev/null +++ b/tests/rest_framework/test_filters.py @@ -0,0 +1,15 @@ + +from django.test import TestCase + +from django_filters.rest_framework import filters +from django_filters.widgets import BooleanWidget + + +class BooleanFilterTests(TestCase): + + def test_widget(self): + # Ensure that `BooleanFilter` uses the correct widget when importing + # from `rest_framework.filters`. + f = filters.BooleanFilter() + + self.assertEqual(f.widget, BooleanWidget) diff --git a/tests/rest_framework/test_filterset.py b/tests/rest_framework/test_filterset.py new file mode 100644 index 0000000..db815af --- /dev/null +++ b/tests/rest_framework/test_filterset.py @@ -0,0 +1,22 @@ + +from django.test import TestCase + +from django_filters.rest_framework import FilterSet, filters +from django_filters.widgets import BooleanWidget + +from ..models import User, Article + + +class FilterSetFilterForFieldTests(TestCase): + + def test_isodatetimefilter(self): + field = Article._meta.get_field('published') + result = FilterSet.filter_for_field(field, 'published') + self.assertIsInstance(result, filters.IsoDateTimeFilter) + self.assertEqual(result.name, 'published') + + def test_booleanfilter_widget(self): + field = User._meta.get_field('is_active') + result = FilterSet.filter_for_field(field, 'is_active') + self.assertIsInstance(result, filters.BooleanFilter) + self.assertEqual(result.widget, BooleanWidget) diff --git a/tests/settings.py b/tests/settings.py index 6f7e1c8..13f9013 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -10,6 +10,7 @@ INSTALLED_APPS = ( 'django.contrib.auth', 'django_filters', 'tests', + 'tests.rest_framework', ) ROOT_URLCONF = 'tests.urls' @@ -24,6 +25,10 @@ TEMPLATES = [{ }] -SILENCED_SYSTEM_CHECKS = [ - '1_7.W001', # Unset MIDDLEWARE_CLASSES warning -] +MIDDLEWARE = [] + + +# help verify that DEFAULTS is importable from conf. +def FILTERS_VERBOSE_LOOKUPS(): + from django_filters.conf import DEFAULTS + return DEFAULTS['VERBOSE_LOOKUPS'] diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..f5806d6 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,64 @@ + +from django.test import TestCase, override_settings + +from django_filters.conf import settings + + +class DefaultSettingsTests(TestCase): + + def test_verbose_loookups(self): + self.assertIsInstance(settings.VERBOSE_LOOKUPS, dict) + self.assertIn('exact', settings.VERBOSE_LOOKUPS) + + def test_help_text_filter(self): + self.assertTrue(settings.HELP_TEXT_FILTER) + + def test_help_text_exclude(self): + self.assertTrue(settings.HELP_TEXT_EXCLUDE) + + +class OverrideSettingsTests(TestCase): + + def test_attribute_override(self): + self.assertIsInstance(settings.VERBOSE_LOOKUPS, dict) + + original = settings.VERBOSE_LOOKUPS + + with override_settings(FILTERS_VERBOSE_LOOKUPS=None): + self.assertIsNone(settings.VERBOSE_LOOKUPS) + + self.assertIs(settings.VERBOSE_LOOKUPS, original) + + def test_missing_attribute_override(self): + # ensure that changed setting behaves correctly when + # not originally present in the user's settings. + from django.conf import settings as dj_settings + self.assertFalse(hasattr(dj_settings, 'FILTERS_HELP_TEXT_FILTER')) + + # Default value + self.assertTrue(settings.HELP_TEXT_FILTER) + + with override_settings(FILTERS_HELP_TEXT_FILTER=None): + self.assertIsNone(settings.HELP_TEXT_FILTER) + + # Revert to default + self.assertTrue(settings.HELP_TEXT_FILTER) + + def test_non_filters_setting(self): + self.assertFalse(hasattr(settings, 'USE_TZ')) + + with override_settings(USE_TZ=False): + self.assertFalse(hasattr(settings, 'USE_TZ')) + + self.assertFalse(hasattr(settings, 'USE_TZ')) + + def test_non_existent_setting(self): + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) + + with override_settings(FILTERS_FOOBAR='blah'): + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) + + self.assertFalse(hasattr(settings, 'FILTERS_FOOBAR')) + self.assertFalse(hasattr(settings, 'FOOBAR')) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 890f5ae..b7871bb 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -1,14 +1,29 @@ +import functools import warnings -from django.test import TestCase +import mock +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings from django_filters import FilterSet -from django_filters.filters import CharFilter +from django_filters.conf import Settings +from django_filters.filters import Filter, CharFilter, MethodFilter +from django_filters.filterset import STRICTNESS from .models import User from .models import NetworkSetting from .models import SubnetMaskField +def silence(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + f(*args, **kwargs) + + return wrapped + + class UserFilter(FilterSet): class Meta: model = User @@ -53,6 +68,177 @@ class FilterSetContainerDeprecationTests(TestCase): self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) +class MethodFilterDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + # old tests + @silence + def test_filtering(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter(action='filter_username') + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username=value + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_external(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + + def filter_username(queryset, value): + return queryset.filter( + username=value + ) + + class F(FilterSet): + username = MethodFilter(action=filter_username) + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'alex'}).qs), + [User.objects.get(username='alex')]) + self.assertEqual(list(F({'username': 'jose'}).qs), + list()) + + @silence + def test_filtering_default_attribute_action(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + def filter_username(self, queryset, value): + return queryset.filter( + username__contains='ke' + ) + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')],) + self.assertEqual(list(F({'username': 'jake'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + self.assertEqual(list(F({'username': 'aaron'}).qs), + [User.objects.get(username='mike'), + User.objects.get(username='jake')]) + + @silence + def test_filtering_default(self): + User.objects.create(username='mike') + User.objects.create(username='jake') + User.objects.create(username='aaron') + + class F(FilterSet): + username = MethodFilter() + email = MethodFilter() + + class Meta: + model = User + fields = ['username'] + + self.assertEqual(list(F().qs), list(User.objects.all())) + self.assertEqual(list(F({'username': 'mike'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'jake'}).qs), + list(User.objects.all())) + self.assertEqual(list(F({'username': 'aaron'}).qs), + list(User.objects.all())) + + +class FilterActionDeprecationTests(TestCase): + + def test_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda x: x) + + class Meta: + model = User + fields = [] + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filter_using_action(self): + qs = mock.NonCallableMock(spec=[]) + action = mock.Mock(spec=['filter']) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + f = Filter(action=action) + result = f.filter(qs, 'value') + action.assert_called_once_with(qs, 'value') + self.assertNotEqual(qs, result) + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_filtering_with_action(self): + User.objects.create(username='alex', status=1) + User.objects.create(username='jacob', status=2) + User.objects.create(username='aaron', status=2) + User.objects.create(username='carl', status=0) + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + username = CharFilter(action=lambda qs, value: ( + qs.filter(**{'username__startswith': value}) + )) + + class Meta: + model = User + fields = ['username'] + + f = F({'username': 'a'}, queryset=User.objects.all()) + self.assertQuerysetEqual( + f.qs, ['alex', 'aaron'], lambda o: o.username, False) + + class FilterSetMetaDeprecationTests(TestCase): def test_fields_not_set(self): with warnings.catch_warnings(record=True) as w: @@ -95,14 +281,438 @@ class FilterSetMetaDeprecationTests(TestCase): warnings.simplefilter("always") class F(FilterSet): - filter_overrides = { - SubnetMaskField: {'filter_class': CharFilter} - } class Meta: model = NetworkSetting + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter}, + } self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message)) self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) + + +class StrictnessDeprecationTests(TestCase): + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + strict = False + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + strict = False + + self.assertEqual(F._meta.strict, False) + + +class FilterOverridesDeprecationTests(TestCase): + + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + filter_overrides = {} + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter}, + } + + class Meta: + model = NetworkSetting + fields = '__all__' + + self.assertDictEqual(F._meta.filter_overrides, { + SubnetMaskField: {'filter_class': CharFilter}, + }) + + self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) + + +class OrderByFieldDeprecationTests(TestCase): + def test_notification(self): + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + order_by_field = 'field' + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_passthrough(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + class F(FilterSet): + order_by_field = 'field' + + self.assertEqual(F._meta.order_by_field, 'field') + + +class OrderByDeprecationTests(TestCase): + def test_order_by_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + order_by = True + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_order_by_field_notification(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + class F(FilterSet): + class Meta: + order_by_field = 'field' + + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_get_order_by_assertion(self): + with self.assertRaises(AssertionError): + class F(FilterSet): + def get_order_by(self): + pass + + def test_get_ordering_field_assertion(self): + with self.assertRaises(AssertionError): + class F(FilterSet): + def get_ordering_field(self): + pass + + +class DeprecatedOrderingFilterSetTests(TestCase): + def setUp(self): + self.alex = User.objects.create(username='alex', status=1) + self.jacob = User.objects.create(username='jacob', status=2) + self.aaron = User.objects.create(username='aaron', status=2) + self.carl = User.objects.create(username='carl', status=0) + self.qs = User.objects.all().order_by('id') + + # old filterset tests + @silence + def test_ordering(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['username', 'status'] + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + f = F({'o': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, [], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + strict = STRICTNESS.IGNORE + + self.assertFalse(F._meta.strict) + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) + + @silence + def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + strict = STRICTNESS.RAISE_VALIDATION_ERROR + + f = F({'o': 'username'}, queryset=self.qs) + with self.assertRaises(ValidationError) as excinfo: + f.qs.all() + self.assertEqual(excinfo.exception.message_dict, + {'o': ['Select a valid choice. username is not one ' + 'of the available choices.']}) + + # No default order_by should get applied. + f = F({}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) + + @silence + def test_ordering_on_different_field(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + f = F({'o': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_uses_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F({'o': 'account'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) + + @silence + def test_reverted_ordering_uses_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F({'o': '-account'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_with_overridden_field_name(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + order_by_field = 'order' + + f = F({'order': 'status'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_descending_set(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['username', '-username'] + + f = F({'o': '-username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + @silence + def test_ordering_descending_unset(self): + """ Test ordering descending works when order_by=True. """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F({'o': '-username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) + + +class DeprecatedOrderingFormTests(TestCase): + @silence + def test_ordering(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + + f = F().form + self.assertEqual(len(f.fields), 3) + self.assertIn('o', f.fields) + self.assertEqual(f.fields['o'].choices, [('status', 'Status')]) + + @silence + def test_ordering_uses_all_fields(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_uses_filter_label(self): + class F(FilterSet): + username = CharFilter(label='Account') + + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('username', 'Account'), + ('-username', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_uses_explicit_filter_name(self): + class F(FilterSet): + account = CharFilter(name='username') + + class Meta: + model = User + fields = ['account', 'status'] + order_by = True + + f = F().form + self.assertEqual(f.fields['o'].choices, [ + ('account', 'Account'), + ('-account', 'Account (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_overridden_field_name(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + order_by_field = 'order' + + f = F().form + self.assertNotIn('o', f.fields) + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [('status', 'Status')]) + + @silence + def test_ordering_with_overridden_field_name_and_descending(self): + """ + Set the `order_by_field` on the filterset and ensure that the + field name is respected. + """ + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status', '-status'] + order_by_field = 'order' + + f = F().form + self.assertNotIn('o', f.fields) + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_overridden_field_name_and_using_all_fields(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = True + order_by_field = 'order' + + f = F().form + self.assertIn('order', f.fields) + self.assertEqual(f.fields['order'].choices, [ + ('username', 'Username'), + ('-username', 'Username (descending)'), + ('status', 'Status'), + ('-status', 'Status (descending)')]) + + @silence + def test_ordering_with_custom_display_names(self): + class F(FilterSet): + class Meta: + model = User + fields = ['username', 'status'] + order_by = [('status', 'Current status')] + + f = F().form + self.assertEqual( + f.fields['o'].choices, [('status', 'Current status')]) + + +class DeprecatedSettingsTests(TestCase): + + def test_filter_help_text(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + with override_settings(FILTERS_HELP_TEXT_FILTER=False): + Settings() + + self.assertEqual(len(w), 1) + self.assertIn("The 'FILTERS_HELP_TEXT_FILTER' setting has been deprecated.", str(w[0].message)) + + def test_exclude_help_text(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + with override_settings(FILTERS_HELP_TEXT_EXCLUDE=False): + Settings() + + self.assertEqual(len(w), 1) + self.assertIn("The 'FILTERS_HELP_TEXT_EXCLUDE' setting has been deprecated.", str(w[0].message)) diff --git a/tests/test_fields.py b/tests/test_fields.py index 1b1d6b4..dd76151 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -111,26 +111,6 @@ class LookupTypeFieldTests(TestCase): f.clean(['12.34', 'lt']), Lookup(to_d(12.34), 'lt')) - @unittest.skipIf(django.VERSION >= (1, 6), - 'Django 1.6 uses html5 fields') - def test_render(self): - inner = forms.DecimalField() - f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')]) - self.assertHTMLEqual(f.widget.render('price', ''), """ - - """) - self.assertHTMLEqual(f.widget.render('price', ['abc', 'lt']), """ - - """) - - @unittest.skipUnless(django.VERSION >= (1, 6), - 'Django 1.6 uses html5 fields') def test_render_used_html5(self): inner = forms.DecimalField() f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')]) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 4fe5e17..acf597c 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -21,7 +21,6 @@ from django_filters.filters import DateFromToRangeFilter from django_filters.filters import DateTimeFromToRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import DurationFilter -from django_filters.filters import MethodFilter from django_filters.filters import MultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import NumberFilter @@ -153,7 +152,6 @@ class ChoiceFilterTests(TestCase): f = F({'status': '0'}) self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - def test_filtering_on_explicitly_defined_field(self): """ Test for #30. @@ -167,6 +165,7 @@ class ChoiceFilterTests(TestCase): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES) + class Meta: model = User fields = ['status'] @@ -186,7 +185,6 @@ class ChoiceFilterTests(TestCase): self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False) - class MultipleChoiceFilterTests(TestCase): def test_filtering(self): @@ -857,11 +855,11 @@ class AllValuesFilterTests(TestCase): class F(FilterSet): username = AllValuesFilter() - strict = False class Meta: model = User fields = ['username'] + strict = False self.assertEqual(list(F().qs), list(User.objects.all())) self.assertEqual(list(F({'username': 'alex'}).qs), @@ -893,24 +891,23 @@ class AllValuesMultipleFilterTests(TestCase): list()) -class MethodFilterTests(TestCase): +class FilterMethodTests(TestCase): + + def setUp(self): + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') def test_filtering(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - class F(FilterSet): - username = MethodFilter(action='filter_username') + username = CharFilter(method='filter_username') class Meta: model = User fields = ['username'] - def filter_username(self, queryset, value): - return queryset.filter( - username=value - ) + def filter_username(self, queryset, name, value): + return queryset.filter(**{name: value}) self.assertEqual(list(F().qs), list(User.objects.all())) self.assertEqual(list(F({'username': 'alex'}).qs), @@ -918,18 +915,12 @@ class MethodFilterTests(TestCase): self.assertEqual(list(F({'username': 'jose'}).qs), list()) - def test_filtering_external(self): - User.objects.create(username='alex') - User.objects.create(username='jacob') - User.objects.create(username='aaron') - - def filter_username(queryset, value): - return queryset.filter( - username=value - ) + def test_filtering_callable(self): + def filter_username(queryset, name, value): + return queryset.filter(**{name: value}) class F(FilterSet): - username = MethodFilter(action=filter_username) + username = CharFilter(method=filter_username) class Meta: model = User @@ -942,57 +933,6 @@ class MethodFilterTests(TestCase): list()) - def test_filtering_default_attribute_action(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - def filter_username(self, queryset, value): - return queryset.filter( - username__contains='ke' - ) - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')],) - self.assertEqual(list(F({'username': 'jake'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - self.assertEqual(list(F({'username': 'aaron'}).qs), - [User.objects.get(username='mike'), - User.objects.get(username='jake')]) - - - def test_filtering_default(self): - User.objects.create(username='mike') - User.objects.create(username='jake') - User.objects.create(username='aaron') - - class F(FilterSet): - username = MethodFilter() - email = MethodFilter() - - class Meta: - model = User - fields = ['username'] - - self.assertEqual(list(F().qs), list(User.objects.all())) - self.assertEqual(list(F({'username': 'mike'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'jake'}).qs), - list(User.objects.all())) - self.assertEqual(list(F({'username': 'aaron'}).qs), - list(User.objects.all())) - - class O2ORelationshipTests(TestCase): def setUp(self): @@ -1682,19 +1622,6 @@ class MiscFilterSetTests(TestCase): f = F({'username': 'alex', 'status': '2'}, queryset=qs) self.assertQuerysetEqual(f.qs, [], lambda o: o.pk) - def test_filter_with_action(self): - class F(FilterSet): - username = CharFilter(action=lambda qs, value: ( - qs.filter(**{'username__startswith': value}))) - - class Meta: - model = User - fields = ['username'] - - f = F({'username': 'a'}, queryset=User.objects.all()) - self.assertQuerysetEqual( - f.qs, ['alex', 'aaron'], lambda o: o.username, False) - def test_filter_with_initial(self): class F(FilterSet): status = ChoiceFilter(choices=STATUS_CHOICES, initial=1) diff --git a/tests/test_filters.py b/tests/test_filters.py index 8aa0ab0..b360a5d 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,10 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals +from collections import OrderedDict from datetime import date, time, timedelta, datetime import mock import warnings -import unittest from django import forms from django.test import TestCase, override_settings @@ -42,6 +42,7 @@ from django_filters.filters import ( BaseInFilter, BaseRangeFilter, UUIDFilter, + OrderingFilter, LOOKUP_TYPES) from tests.models import Book, User @@ -112,29 +113,32 @@ class FilterTests(TestCase): def test_field_params(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(name='somefield', label='somelabel', - widget='somewidget') + widget='somewidget') f.field mocked.assert_called_once_with(required=False, - label='somelabel', widget='somewidget', help_text=mock.ANY) + label='somelabel', + widget='somewidget', + help_text=mock.ANY) def test_field_extra_params(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(someattr='someattr') f.field mocked.assert_called_once_with(required=mock.ANY, - label=mock.ANY, widget=mock.ANY, help_text=mock.ANY, - someattr='someattr') + label=mock.ANY, widget=mock.ANY, + help_text=mock.ANY, + someattr='someattr') def test_field_with_required_filter(self): with mock.patch.object(Filter, 'field_class', - spec=['__call__']) as mocked: + spec=['__call__']) as mocked: f = Filter(required=True) f.field - mocked.assert_called_once_with(required=True, - label=mock.ANY, widget=mock.ANY, help_text=mock.ANY) + mocked.assert_called_once_with(required=True, label=mock.ANY, + widget=mock.ANY, help_text=mock.ANY) def test_filtering(self): qs = mock.Mock(spec=['filter']) @@ -193,12 +197,12 @@ class FilterTests(TestCase): qs.filter.assert_called_once_with(somefield__exact='value') self.assertNotEqual(qs, result) - def test_filter_using_action(self): + def test_filter_using_method(self): qs = mock.NonCallableMock(spec=[]) - action = mock.Mock(spec=['filter']) - f = Filter(action=action) + method = mock.Mock() + f = Filter(method=method) result = f.filter(qs, 'value') - action.assert_called_once_with(qs, 'value') + method.assert_called_once_with(qs, None, 'value') self.assertNotEqual(qs, result) def test_filtering_uses_distinct(self): @@ -436,7 +440,7 @@ class MultipleChoiceFilterTests(TestCase): [5, ]), ((books[3].pk, books[4].pk), []), - ) + ) users = User.objects.all() for item in filter_list: @@ -511,6 +515,21 @@ class ModelMultipleChoiceFilterTests(TestCase): self.assertIsInstance(field, forms.ModelMultipleChoiceField) self.assertEqual(field.queryset, qs) + def test_filtering_to_field_name(self): + qs = User.objects.all() + f = ModelMultipleChoiceFilter(name='first_name', + to_field_name='first_name', + queryset=qs) + user = User.objects.create(first_name='Firstname') + + self.assertEqual(f.get_filter_predicate(user), + {'first_name': 'Firstname'}) + self.assertEqual(f.get_filter_predicate('FilterValue'), + {'first_name': 'FilterValue'}) + + self.assertEqual(list(f.filter(qs, ['Firstname'])), [user]) + self.assertEqual(list(f.filter(qs, [user])), [user]) + class NumberFilterTests(TestCase): @@ -858,7 +877,8 @@ class AllValuesFilterTests(TestCase): def test_default_field_with_assigning_model(self): mocked = mock.Mock() chained_call = '.'.join(['_default_manager', 'distinct.return_value', - 'order_by.return_value', 'values_list.return_value']) + 'order_by.return_value', + 'values_list.return_value']) mocked.configure_mock(**{chained_call: iter([])}) f = AllValuesFilter() f.model = mocked @@ -976,3 +996,104 @@ class BaseRangeFilterTests(TestCase): f = NumberInFilter() f.filter(qs, [1, 2]) qs.filter.assert_called_once_with(None__range=[1, 2]) + + +class OrderingFilterTests(TestCase): + def test_default_field(self): + f = OrderingFilter() + field = f.field + self.assertIsInstance(field, forms.ChoiceField) + + def test_filtering(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + f.filter(qs, ['a', 'b']) + qs.order_by.assert_called_once_with('a', 'b') + + def test_filtering_descending(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + f.filter(qs, ['-a']) + qs.order_by.assert_called_once_with('-a') + + def test_filtering_with_fields(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter(fields={'a': 'b'}) + f.filter(qs, ['b', '-b']) + qs.order_by.assert_called_once_with('a', '-a') + + def test_filtering_skipped_with_none_value(self): + qs = mock.Mock(spec=['order_by']) + f = OrderingFilter() + result = f.filter(qs, None) + self.assertEqual(qs, result) + + def test_choices_unaltered(self): + # provided 'choices' should not be altered when 'fields' is present + f = OrderingFilter( + choices=(('a', 'A'), ('b', 'B')), + fields=(('a', 'c'), ('b', 'd')), + ) + + self.assertSequenceEqual(f.field.choices, ( + ('a', 'A'), + ('b', 'B'), + )) + + def test_choices_from_fields(self): + f = OrderingFilter( + fields=(('a', 'c'), ('b', 'd')), + ) + + self.assertSequenceEqual(f.field.choices, ( + ('c', 'C'), + ('-c', 'C (descending)'), + ('d', 'D'), + ('-d', 'D (descending)'), + )) + + def test_field_labels(self): + f = OrderingFilter( + fields=(('a', 'c'), ('b', 'd')), + field_labels={'a': 'foo'}, + ) + + self.assertSequenceEqual(f.field.choices, ( + ('c', 'foo'), + ('-c', 'foo (descending)'), + ('d', 'D'), + ('-d', 'D (descending)'), + )) + + def test_normalize_fields(self): + f = OrderingFilter.normalize_fields + O = OrderedDict + + self.assertIn('a', f({'a': 'b'})) + + self.assertEqual( + f(O([('a', 'b'), ('c', 'd')])), + O([('a', 'b'), ('c', 'd')]) + ) + + self.assertEqual( + f([('a', 'b'), ('c', 'd')]), + O([('a', 'b'), ('c', 'd')]) + ) + + self.assertEqual( + f(['a', 'b']), + O([('a', 'a'), ('b', 'b')]) + ) + + with self.assertRaises(AssertionError) as ctx: + f(None) + self.assertEqual(str(ctx.exception), "'fields' must be an iterable (e.g., a list, tuple, or mapping).") + + with self.assertRaises(AssertionError) as ctx: + f([('a', 'b', 'c')]) + self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.") + + with self.assertRaises(AssertionError) as ctx: + f([0, 1, 2]) + self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.") diff --git a/tests/test_filterset.py b/tests/test_filterset.py index b2f76b6..477191a 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -4,13 +4,12 @@ import mock import unittest import django -from django.core.exceptions import ValidationError from django.db import models from django.test import TestCase from django_filters.filterset import FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS -from django_filters.filterset import STRICTNESS +from django_filters.filters import Filter from django_filters.filters import BooleanFilter from django_filters.filters import CharFilter from django_filters.filters import NumberFilter @@ -20,6 +19,8 @@ from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import UUIDFilter from django_filters.filters import BaseInFilter from django_filters.filters import BaseRangeFilter +from django_filters.filters import DateRangeFilter +from django_filters.filters import FilterMethod from django_filters.widgets import BooleanWidget @@ -37,7 +38,6 @@ from .models import BankAccount from .models import Node from .models import DirectedNode from .models import Worker -from .models import HiredWorker from .models import Business from .models import UUIDTestModel @@ -227,14 +227,15 @@ class FilterSetFilterForLookupTests(TestCase): def test_isnull_with_filter_overrides(self): class OFilterSet(FilterSet): - filter_overrides = { - models.BooleanField: { - 'filter_class': BooleanFilter, - 'extra': lambda f: { - 'widget': BooleanWidget, + class Meta: + filter_overrides = { + models.BooleanField: { + 'filter_class': BooleanFilter, + 'extra': lambda f: { + 'widget': BooleanWidget, + }, }, - }, - } + } f = Article._meta.get_field('author') result, params = OFilterSet.filter_for_lookup(f, 'isnull') @@ -392,9 +393,10 @@ class FilterSetClassCreationTests(TestCase): class Meta: model = Book - fields = ('username', 'price', 'other') + fields = ('username', 'price', 'other', 'another') self.assertEqual(excinfo.exception.args, ( - "Meta.fields contains a field that isn't defined on this FilterSet: other",)) + "Meta.fields contains a field that isn't defined " + "on this FilterSet: other",)) def test_meta_fields_dictionary_containing_unknown(self): with self.assertRaises(TypeError): @@ -405,7 +407,7 @@ class FilterSetClassCreationTests(TestCase): fields = {'id': ['exact'], 'title': ['exact'], 'other': ['exact'], - } + } def test_meta_exlude_with_declared_and_declared_wins(self): class F(FilterSet): @@ -483,13 +485,14 @@ class FilterSetClassCreationTests(TestCase): def test_custom_field_gets_filter_from_override(self): class F(FilterSet): - filter_overrides = { - SubnetMaskField: {'filter_class': CharFilter}} - class Meta: model = NetworkSetting fields = '__all__' + filter_overrides = { + SubnetMaskField: {'filter_class': CharFilter} + } + self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask']) def test_filterset_for_proxy_model(self): @@ -560,190 +563,6 @@ class FilterSetInstantiationTests(TestCase): self.assertEqual(f.queryset, m) -class FilterSetOrderingTests(TestCase): - - def setUp(self): - self.alex = User.objects.create(username='alex', status=1) - self.jacob = User.objects.create(username='jacob', status=2) - self.aaron = User.objects.create(username='aaron', status=2) - self.carl = User.objects.create(username='carl', status=0) - # user_ids = list(User.objects.all().values_list('pk', flat=True)) - self.qs = User.objects.all().order_by('id') - - def test_ordering_when_unbound(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F(queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', 'status'] - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_on_unknown_value(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, [], lambda o: o.username) - - def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): - class F(FilterSet): - strict = STRICTNESS.IGNORE - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - self.assertFalse(F.strict) - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) - - def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): - class F(FilterSet): - strict = STRICTNESS.RAISE_VALIDATION_ERROR - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F({'o': 'username'}, queryset=self.qs) - with self.assertRaises(ValidationError) as excinfo: - f.qs.all() - self.assertEqual(excinfo.exception.message_dict, - {'o': ['Select a valid choice. username is not one ' - 'of the available choices.']}) - - # No default order_by should get applied. - f = F({}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) - - def test_ordering_on_different_field(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_uses_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F({'o': 'account'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - def test_reverted_ordering_uses_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F({'o': '-account'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - def test_ordering_with_overridden_field_name(self): - """ - Set the `order_by_field` on the queryset and ensure that the - field name is respected. - """ - class F(FilterSet): - order_by_field = 'order' - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F({'order': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username) - - def test_ordering_descending_set(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', '-username'] - - f = F({'o': '-username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - def test_ordering_descending_unset(self): - """ Test ordering descending works when order_by=True. """ - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F({'o': '-username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username) - - def test_custom_ordering(self): - - class F(FilterSet): - debug = True - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['username', 'status'] - - def get_order_by(self, order_choice): - if order_choice == 'status': - return ['status', 'username'] - return super(F, self).get_order_by(order_choice) - - f = F({'o': 'username'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username) - - f = F({'o': 'status'}, queryset=self.qs) - self.assertQuerysetEqual( - f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username) - - class FilterSetTogetherTests(TestCase): def setUp(self): @@ -785,6 +604,106 @@ class FilterSetTogetherTests(TestCase): self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk) +# test filter.method here, as it depends on its parent FilterSet +class FilterMethodTests(TestCase): + + def test_none(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method=None) + self.assertIsNone(f.method) + + # passing method=None should not modify filter function + self.assertIs(f.filter, TestFilter.filter) + + def test_method_name(self): + class F(FilterSet): + f = Filter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_callable(self): + def filter_f(qs, name, value): + pass + + class F(FilterSet): + f = Filter(method=filter_f) + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, filter_f) + self.assertEqual(f.filters['f'].filter.method, filter_f) + self.assertIsInstance(f.filters['f'].filter, FilterMethod) + + def test_method_with_overridden_filter(self): + # Some filter classes override the base filter() method. We need + # to ensure that passing a method argument still works correctly + class F(FilterSet): + f = DateRangeFilter(method='filter_f') + + def filter_f(self, qs, name, value): + pass + + f = F({}, queryset=User.objects.all()) + self.assertEqual(f.filters['f'].method, 'filter_f') + self.assertEqual(f.filters['f'].filter.method, f.filter_f) + + def test_parent_unresolvable(self): + f = Filter(method='filter_f') + with self.assertRaises(AssertionError) as w: + f.filter(User.objects.all(), 0) + + self.assertIn("'None'", str(w.exception)) + self.assertIn('parent', str(w.exception)) + self.assertIn('filter_f', str(w.exception)) + + def test_method_unresolvable(self): + class F(FilterSet): + f = Filter(method='filter_f') + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_uncallable(self): + class F(FilterSet): + f = Filter(method='filter_f') + filter_f = 4 + + f = F({}, queryset=User.objects.all()) + + with self.assertRaises(AssertionError) as w: + f.filters['f'].filter(User.objects.all(), 0) + + self.assertIn('%s.%s' % (F.__module__, F.__name__), str(w.exception)) + self.assertIn('.filter_f()', str(w.exception)) + + def test_method_set_unset(self): + # use a mock to bypass bound/unbound method equality + class TestFilter(Filter): + filter = mock.Mock() + + f = TestFilter(method='filter_f') + self.assertEqual(f.method, 'filter_f') + self.assertIsInstance(f.filter, FilterMethod) + + # setting None should revert to Filter.filter + f.method = None + self.assertIsNone(f.method) + self.assertIs(f.filter, TestFilter.filter) + + @unittest.skip('TODO: remove when relevant deprecations have been completed') class MiscFilterSetTests(TestCase): diff --git a/tests/test_forms.py b/tests/test_forms.py index 1666291..a3da8c9 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -164,116 +164,6 @@ class FilterSetFormTests(TestCase): self.assertTrue(f.is_bound) self.assertEqual(f.data, {'title': 'Some book'}) - def test_ordering(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F().form - self.assertEqual(len(f.fields), 3) - self.assertIn('o', f.fields) - self.assertEqual(f.fields['o'].choices, [('status', 'Status')]) - - def test_ordering_uses_all_fields(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, - [('username', 'Username'), ('-username', 'Username (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_uses_filter_label(self): - class F(FilterSet): - username = CharFilter(label='Account') - - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, - [('username', 'Account'), ('-username', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_uses_explicit_filter_name(self): - class F(FilterSet): - account = CharFilter(name='username') - - class Meta: - model = User - fields = ['account', 'status'] - order_by = True - - f = F().form - self.assertEqual(f.fields['o'].choices, - [('account', 'Account'), ('-account', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_with_overridden_field_name(self): - """ - Set the `order_by_field` on the queryset and ensure that the - field name is respected. - """ - class F(FilterSet): - order_by_field = 'order' - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status'] - - f = F().form - self.assertNotIn('o', f.fields) - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, [('status', 'Status')]) - - def test_ordering_with_overridden_field_name_and_descending(self): - """ - Set the `order_by_field` on the queryset and ensure that the - field name is respected. - """ - class F(FilterSet): - order_by_field = 'order' - - class Meta: - model = User - fields = ['username', 'status'] - order_by = ['status', '-status'] - - f = F().form - self.assertNotIn('o', f.fields) - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_with_overridden_field_name_and_using_all_fields(self): - class F(FilterSet): - order_by_field = 'order' - - class Meta: - model = User - fields = ['username', 'status'] - order_by = True - - f = F().form - self.assertIn('order', f.fields) - self.assertEqual(f.fields['order'].choices, - [('username', 'Username'), ('-username', 'Username (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')]) - - def test_ordering_with_custom_display_names(self): - class F(FilterSet): - class Meta: - model = User - fields = ['username', 'status'] - order_by = [('status', 'Current status')] - - f = F().form - self.assertEqual( - f.fields['o'].choices, [('status', 'Current status')]) - def test_limit_choices_to(self): User.objects.create(username='inactive', is_active=False, status=REGULAR) User.objects.create(username='active', is_active=True, status=REGULAR) @@ -284,9 +174,9 @@ class FilterSetFormTests(TestCase): model = ManagerGroup fields = ['users', 'manager'] f = F().form - self.assertEquals( + self.assertEqual( list(f.fields['users'].choices), [(2, 'active')] ) - self.assertEquals( + self.assertEqual( list(f.fields['manager'].choices), [('', '---------'), (3, 'manager')] ) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 57caea3..2cfb93c 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -78,15 +78,13 @@ class LinkWidgetTests(TestCase): def test_widget_with_option_groups(self): choices = ( ('Audio', ( - ('vinyl', 'Vinyl'), - ('cd', 'CD'), - ) - ), + ('vinyl', 'Vinyl'), + ('cd', 'CD'), + )), ('Video', ( - ('vhs', 'VHS Tape'), - ('dvd', 'DVD'), - ) - ), + ('vhs', 'VHS Tape'), + ('dvd', 'DVD'), + )), ('unknown', 'Unknown'), ) diff --git a/tox.ini b/tox.ini index b43f691..957d43e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - {py27,py33,py34,py35}-django18, - {py27,py34,py35}-django{19,110}, - {py27,py34,py35}-django-latest + {py27,py33,py34,py35}-django18-restframework{33,34}, + {py27,py34,py35}-django{19,110}-restframework34, + {py27,py34,py35}-django-latest-restframework34 [testenv] @@ -13,7 +13,7 @@ basepython = py35: python3.5 -commands = ./runtests.py +commands = ./runtests.py {posargs} setenv = PYTHONDONTWRITEBYTECODE=1 deps = @@ -21,4 +21,6 @@ deps = django19: django>=1.9.0,<1.10.0 django110: django>=1.10.0,<1.11.0 django-latest: https://github.com/django/django/archive/master.tar.gz - -rrequirements/test.txt + restframework33: djangorestframework>=3.3,<3.4 + restframework34: djangorestframework>=3.4,<3.5 + -rrequirements/test-ci.txt