Merge pull request #556 from carltongibson/develop

Version 1.0 Release
This commit is contained in:
Carlton Gibson 2016-11-17 15:43:53 +01:00 committed by GitHub
commit f5b2d636d1
46 changed files with 2093 additions and 1498 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.15.3
current_version = 1.0.0
commit = False
tag = False
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?

View File

@ -1,45 +1,16 @@
language: python
sudo: false
language: python
python: '3.5'
cache: pip
python:
- "2.7"
- "3.3"
- "3.4"
- "3.5"
env:
- 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 -Ur requirements/test-ci.txt
- travis_retry pip install $DJANGO $REST_FRAMEWORK
- pip install coverage tox
script:
- $W coverage run --source django_filters runtests.py -v 2
- coverage erase
- tox
after_success:
- coverage combine
- coverage report
notifications:
email: false
matrix:
exclude:
- python: "3.3"
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" REST_FRAMEWORK="djangorestframework>=3.4,<3.5"
- python: "3.3"
env: DJANGO="django>=1.9.0,<1.10.0" REST_FRAMEWORK="djangorestframework>=3.4,<3.5"
include:
- python: "3.5"
env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5'
allow_failures:
- env: W='python -W error -m' DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5'
- env: DJANGO='https://github.com/django/django/archive/master.tar.gz' REST_FRAMEWORK='djangorestframework>=3.4,<3.5'
fast_finish: true

View File

@ -1,3 +1,20 @@
Version 1.0 (2016-11-17)
------------------------
This release removes all the deprecated code from 0.14 and 0.15 for 1.0 #480.
Please see the `Migration Notes`__ for details of how to migrate.
Stick with 0.15.3 if you're not ready to update.
__ https://github.com/carltongibson/django-filter/blob/develop/docs/migration.txt
The release includes a number of small fixes and documentation updates.
See the `1.0 Milestone`__ for full details.
__ https://github.com/carltongibson/django-filter/milestone/8?closed=1
Version 0.15.3 (2016-10-17)
---------------------------
@ -60,6 +77,8 @@ __ https://github.com/carltongibson/django-filter/blob/develop/docs/migration.tx
Version 0.14.0 (2016-08-14)
---------------------------
* Confirmed support for Django 1.10.
* Add support for filtering on DurationField (new in Django 1.8).
* Fix UUIDFilter import issue

7
Makefile Normal file
View File

@ -0,0 +1,7 @@
.PHONY: deps, test
deps:
pip install -r ./requirements/test.txt
test:
./runtests.py

View File

@ -12,9 +12,9 @@ Full documentation on `read the docs`_.
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
* **Python**: 2.7, 3.3, 3.4, 3.5
* **Django**: 1.8, 1.9, 1.10
* **DRF**: 3.4, 3.5
Installation
------------

View File

@ -1,9 +1,10 @@
# flake8: noqa
from __future__ import absolute_import
from .constants import STRICTNESS
from .filterset import FilterSet
from .filters import *
__version__ = '0.15.3'
__version__ = '1.0.0'
def parse_version(version):

View File

@ -14,9 +14,11 @@ except ImportError:
is_crispy = 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms
# coreapi only compatible with DRF 3.4+
# coreapi is optional (Note that uritemplate is a dependency of coreapi)
# Fixes #525 - cannot simply import from rest_framework.compat, due to
# import issues w/ django-guardian.
try:
from rest_framework.compat import coreapi
import coreapi
except ImportError:
coreapi = None

View File

@ -3,12 +3,22 @@ from django.conf import settings as dj_settings
from django.core.signals import setting_changed
from django.utils.translation import ugettext_lazy as _
from .constants import STRICTNESS
from .utils import deprecate
DEFAULTS = {
'DISABLE_HELP_TEXT': False,
'HELP_TEXT_FILTER': True,
'HELP_TEXT_EXCLUDE': True,
# empty/null choices
'EMPTY_CHOICE_LABEL': '---------',
'NULL_CHOICE_LABEL': None,
'NULL_CHOICE_VALUE': 'null',
'STRICTNESS': STRICTNESS.RETURN_NO_RESULTS,
'VERBOSE_LOOKUPS': {
# transforms don't need to be verbose, since their expressions are chained
'date': _('date'),

View File

@ -0,0 +1,21 @@
ALL_FIELDS = '__all__'
class STRICTNESS:
class IGNORE:
pass
class RETURN_NO_RESULTS:
pass
class RAISE_VALIDATION_ERROR:
pass
# Values of False & True chosen for backward compatability reasons.
# Originally, these were the only options.
_LEGACY = {
False: IGNORE,
True: RETURN_NO_RESULTS,
"RAISE": RAISE_VALIDATION_ERROR,
}

View File

@ -1,7 +1,6 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import warnings
from collections import OrderedDict
from datetime import timedelta
@ -19,11 +18,15 @@ from .fields import (
Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
)
from .utils import deprecate, pretty_name
from .utils import label_for_filter, pretty_name
__all__ = [
'AllValuesFilter',
'AllValuesMultipleFilter',
'BaseCSVFilter',
'BaseInFilter',
'BaseRangeFilter',
'BooleanFilter',
'CharFilter',
'ChoiceFilter',
@ -35,7 +38,6 @@ __all__ = [
'DurationFilter',
'Filter',
'IsoDateTimeFilter',
'MethodFilter',
'ModelChoiceFilter',
'ModelMultipleChoiceFilter',
'MultipleChoiceFilter',
@ -46,6 +48,7 @@ __all__ = [
'TimeFilter',
'TimeRangeFilter',
'TypedChoiceFilter',
'TypedMultipleChoiceFilter',
'UUIDFilter',
]
@ -56,27 +59,16 @@ 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)
class Filter(object):
creation_counter = 0
field_class = forms.Field
def __init__(self, name=None, label=None, widget=None, action=None, method=None,
lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs):
def __init__(self, name=None, label=None, widget=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:
_lookup_type_warning()
self.lookup_expr = kwargs.pop('lookup_type')
self.widget = widget
self.required = required
@ -115,29 +107,28 @@ class Filter(object):
return locals()
method = property(**method())
def lookup_type():
def label():
def fget(self):
_lookup_type_warning()
return self.lookup_expr
if self._label is None and hasattr(self, 'parent'):
model = self.parent._meta.model
self._label = label_for_filter(
model, self.name, self.lookup_expr, self.exclude
)
return self._label
def fset(self, value):
_lookup_type_warning()
self.lookup_expr = value
self._label = value
return locals()
lookup_type = property(**lookup_type())
label = property(**label())
@property
def field(self):
if not hasattr(self, '_field'):
help_text = self.extra.pop('help_text', None)
if help_text is None:
if self.exclude and settings.HELP_TEXT_EXCLUDE:
help_text = _('This is an exclusion filter')
elif not self.exclude and settings.HELP_TEXT_FILTER:
help_text = _('Filter')
else:
help_text = ''
field_kwargs = self.extra.copy()
if settings.DISABLE_HELP_TEXT:
field_kwargs.pop('help_text', None)
if (self.lookup_expr is None or
isinstance(self.lookup_expr, (list, tuple))):
@ -161,12 +152,12 @@ class Filter(object):
lookup.append(choice)
self._field = LookupTypeField(self.field_class(
required=self.required, widget=self.widget, **self.extra),
lookup, required=self.required, label=self.label, help_text=help_text)
required=self.required, widget=self.widget, **field_kwargs),
lookup, required=self.required, label=self.label)
else:
self._field = self.field_class(required=self.required,
label=self.label, widget=self.widget,
help_text=help_text, **self.extra)
**field_kwargs)
return self._field
def filter(self, qs, value):
@ -194,6 +185,39 @@ class BooleanFilter(Filter):
class ChoiceFilter(Filter):
field_class = forms.ChoiceField
def __init__(self, *args, **kwargs):
empty_label = kwargs.pop('empty_label', settings.EMPTY_CHOICE_LABEL)
null_label = kwargs.pop('null_label', settings.NULL_CHOICE_LABEL)
null_value = kwargs.pop('null_value', settings.NULL_CHOICE_VALUE)
self.null_value = null_value
if 'choices' in kwargs:
choices = kwargs.get('choices')
# coerce choices to list
if callable(choices):
choices = choices()
choices = list(choices)
# create the empty/null choices that prepend the original choices
prepend = []
if empty_label is not None:
prepend.append(('', empty_label))
if null_label is not None:
prepend.append((null_value, null_label))
kwargs['choices'] = prepend + choices
super(ChoiceFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
if value != self.null_value:
return super(ChoiceFilter, self).filter(qs, value)
qs = self.get_method(qs)(**{'%s__%s' % (self.name, self.lookup_expr): None})
return qs.distinct() if self.distinct else qs
class TypedChoiceFilter(Filter):
field_class = forms.TypedChoiceField
@ -223,8 +247,8 @@ class MultipleChoiceFilter(Filter):
`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` as to-many relationships will generally
require this.
"""
field_class = forms.MultipleChoiceField
@ -278,6 +302,10 @@ class MultipleChoiceFilter(Filter):
return {self.name: v}
class TypedMultipleChoiceFilter(MultipleChoiceFilter):
field_class = forms.TypedMultipleChoiceField
class DateFilter(Filter):
field_class = forms.DateField
@ -307,11 +335,62 @@ class DurationFilter(Filter):
field_class = forms.DurationField
class ModelChoiceFilter(Filter):
class QuerySetRequestMixin(object):
"""
Add callable functionality to filters that support the ``queryset``
argument. If the ``queryset`` is callable, then it **must** accept the
``request`` object as a single argument.
This is useful for filtering querysets by properties on the ``request``
object, such as the user.
Example::
def departments(request):
company = request.user.company
return company.department_set.all()
class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...
The above example restricts the set of departments to those in the logged-in
user's associated company.
"""
def __init__(self, *args, **kwargs):
self.queryset = kwargs.get('queryset')
super(QuerySetRequestMixin, self).__init__(*args, **kwargs)
def get_request(self):
try:
return self.parent.request
except AttributeError:
return None
def get_queryset(self, request):
queryset = self.queryset
if callable(queryset):
return queryset(request)
return queryset
@property
def field(self):
request = self.get_request()
queryset = self.get_queryset(request)
if queryset is not None:
self.extra['queryset'] = queryset
return super(QuerySetRequestMixin, self).field
class ModelChoiceFilter(QuerySetRequestMixin, Filter):
field_class = forms.ModelChoiceField
class ModelMultipleChoiceFilter(MultipleChoiceFilter):
class ModelMultipleChoiceFilter(QuerySetRequestMixin, MultipleChoiceFilter):
field_class = forms.ModelMultipleChoiceField
@ -388,6 +467,10 @@ class DateRangeFilter(ChoiceFilter):
def __init__(self, *args, **kwargs):
kwargs['choices'] = [
(key, value[0]) for key, value in six.iteritems(self.options)]
# empty/null choices not relevant
kwargs.setdefault('empty_label', None)
kwargs.setdefault('null_label', None)
super(DateRangeFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
@ -440,6 +523,7 @@ class BaseCSVFilter(Filter):
base_field_class = BaseCSVField
def __init__(self, *args, **kwargs):
kwargs.setdefault('help_text', _('Multiple values may be separated by commas.'))
super(BaseCSVFilter, self).__init__(*args, **kwargs)
class ConcreteCSVField(self.base_field_class, self.field_class):
@ -535,6 +619,7 @@ class OrderingFilter(BaseCSVFilter, ChoiceFilter):
kwargs['choices'] = self.build_choices(fields, field_labels)
kwargs.setdefault('label', _('Ordering'))
kwargs.setdefault('null_label', None)
super(OrderingFilter, self).__init__(*args, **kwargs)
def get_ordering_value(self, param):
@ -576,61 +661,18 @@ class OrderingFilter(BaseCSVFilter, ChoiceFilter):
def build_choices(self, fields, labels):
ascending = [
(param, labels.get(field, pretty_name(param)))
(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
('-%s' % param, labels.get('-%s' % param, self.descending_fmt % label))
for param, label 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)
# If the action is a string store the action and set the action to our own filter method
# so it can be backwards compatible and work as expected, the parent will still treat it as
# a filter that has an action
self.parent_action = ''
text_types = (str, six.text_type)
if type(action) in text_types:
self.parent_action = str(action)
kwargs.update({
'action': self.filter
})
# Call the parent
super(MethodFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
"""
This filter method will act as a proxy for the actual method we want to
call.
It will try to find the method on the parent filterset,
if not it attempts to search for the method `field_{{attribute_name}}`.
Otherwise it defaults to just returning the queryset.
"""
parent = getattr(self, 'parent', None)
parent_filter_method = getattr(parent, self.parent_action, None)
if not parent_filter_method:
func_str = 'filter_{0}'.format(self.name)
parent_filter_method = getattr(parent, func_str, None)
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

View File

@ -10,99 +10,30 @@ from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields.related import ForeignObjectRel
from django.utils import six
from django.utils.text import capfirst
from django.utils.translation import ugettext as _
from .conf import settings
from .compat import remote_field, remote_queryset
from .constants import ALL_FIELDS, STRICTNESS
from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter,
ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter,
DurationFilter, OrderingFilter)
from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, pretty_name, deprecate
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, DurationFilter)
from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field
ORDER_BY_FIELD = 'o'
class STRICTNESS(object):
def get_filter_name(field_name, lookup_expr):
"""
Values of False & True chosen for backward compatability reasons.
Originally, these were the only options.
Combine a field name and lookup expression into a usable filter name.
Exact lookups are the implicit default, so "exact" is stripped from the
end of the filter name.
"""
IGNORE = False
RETURN_NO_RESULTS = True
RAISE_VALIDATION_ERROR = "RAISE"
filter_name = LOOKUP_SEP.join([field_name, lookup_expr])
# This also works with transformed exact lookups, such as 'date__exact'
_exact = LOOKUP_SEP + 'exact'
if filter_name.endswith(_exact):
filter_name = filter_name[:-len(_exact)]
def get_declared_filters(bases, attrs, with_base_filters=True):
filters = []
for filter_name, obj in list(attrs.items()):
if isinstance(obj, Filter):
obj = attrs.pop(filter_name)
if getattr(obj, 'name', None) is None:
obj.name = filter_name
filters.append((filter_name, obj))
filters.sort(key=lambda x: x[1].creation_counter)
if with_base_filters:
for base in bases[::-1]:
if hasattr(base, 'base_filters'):
filters = list(base.base_filters.items()) + filters
else:
for base in bases[::-1]:
if hasattr(base, 'declared_filters'):
filters = list(base.declared_filters.items()) + filters
return OrderedDict(filters)
def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
filter_for_reverse_field=None):
field_dict = OrderedDict()
# Setting exclude with no fields implies all other fields.
if exclude is not None and fields is None:
fields = '__all__'
# All implies all db fields associated with a filter_class.
if fields == '__all__':
fields = get_all_model_fields(model)
# Loop through the list of fields.
for f in fields:
# Skip the field if excluded.
if exclude is not None and f in exclude:
continue
field = get_model_field(model, f)
# Do nothing if the field doesn't exist.
if field is None:
field_dict[f] = None
continue
if isinstance(field, ForeignObjectRel):
filter_ = filter_for_reverse_field(field, f)
if filter_:
field_dict[f] = filter_
# If fields is a dictionary, it must contain lists.
elif isinstance(fields, dict):
# Create a filter for each lookup type.
for lookup_expr in fields[f]:
filter_ = filter_for_field(field, f, lookup_expr)
if filter_:
filter_name = LOOKUP_SEP.join([f, lookup_expr])
# Don't add "exact" to filter names
_exact = LOOKUP_SEP + 'exact'
if filter_name.endswith(_exact):
filter_name = filter_name[:-len(_exact)]
field_dict[filter_name] = filter_
# If fields is a list, it contains strings.
else:
filter_ = filter_for_field(field, f)
if filter_:
field_dict[f] = filter_
return field_dict
return filter_name
def get_full_clean_override(together):
@ -132,35 +63,13 @@ def get_full_clean_override(together):
class FilterSetOptions(object):
def __init__(self, options=None):
if getattr(options, 'model', None) is not None:
if not hasattr(options, 'fields') and not hasattr(options, 'exclude'):
deprecate(
"Not setting Meta.fields with Meta.model is undocumented behavior "
"and may result in unintentionally exposing filter fields. This has "
"been deprecated in favor of setting Meta.fields = '__all__' or by "
"setting the Meta.exclude attribute.", 1)
elif getattr(options, 'fields', -1) is None:
deprecate(
"Setting 'Meta.fields = None' is undocumented behavior and has been "
"deprecated in favor of Meta.fields = '__all__'.", 1)
self.model = getattr(options, 'model', None)
self.fields = getattr(options, 'fields', None)
self.exclude = getattr(options, 'exclude', None)
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.strict = getattr(options, 'strict', None)
self.form = getattr(options, 'form', forms.Form)
@ -169,64 +78,36 @@ class FilterSetOptions(object):
class FilterSetMetaclass(type):
def __new__(cls, name, bases, attrs):
try:
parents = [b for b in bases if issubclass(b, FilterSet)]
except NameError:
# We are defining FilterSet itself here
parents = None
declared_filters = get_declared_filters(bases, attrs, False)
new_class = super(
FilterSetMetaclass, cls).__new__(cls, name, bases, attrs)
attrs['declared_filters'] = cls.get_declared_filters(bases, attrs)
if not parents:
return new_class
new_class = super(FilterSetMetaclass, cls).__new__(cls, name, bases, attrs)
new_class._meta = FilterSetOptions(getattr(new_class, 'Meta', None))
new_class.base_filters = new_class.get_filters()
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:
filters = new_class.filters_for_model(opts.model, opts)
filters.update(declared_filters)
else:
filters = declared_filters
not_defined = next((k for k, v in filters.items() if v is None), False)
if not_defined:
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
@classmethod
def get_declared_filters(cls, bases, attrs):
filters = [
(filter_name, attrs.pop(filter_name))
for filter_name, obj in list(attrs.items())
if isinstance(obj, Filter)
]
# Default the `filter.name` to the attribute name on the filterset
for filter_name, f in filters:
if getattr(f, 'name', None) is None:
f.name = filter_name
filters.sort(key=lambda x: x[1].creation_counter)
# merge declared filters from base classes
for base in reversed(bases):
if hasattr(base, 'declared_filters'):
filters = list(base.declared_filters.items()) + filters
return OrderedDict(filters)
FILTER_FOR_DBFIELD_DEFAULTS = {
models.AutoField: {'filter_class': NumberFilter},
@ -277,7 +158,7 @@ FILTER_FOR_DBFIELD_DEFAULTS = {
class BaseFilterSet(object):
FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS
def __init__(self, data=None, queryset=None, prefix=None, strict=None):
def __init__(self, data=None, queryset=None, prefix=None, strict=None, request=None):
self.is_bound = data is not None
self.data = data or {}
if queryset is None:
@ -286,43 +167,35 @@ class BaseFilterSet(object):
self.form_prefix = prefix
# What to do on on validation errors
self.strict = self._meta.strict if strict is None else strict
# Fallback to meta, then settings strictness
if strict is None:
strict = self._meta.strict
if strict is None:
strict = settings.STRICTNESS
# transform legacy values
self.strict = STRICTNESS._LEGACY.get(strict, strict)
self.request = request
self.filters = copy.deepcopy(self.base_filters)
# propagate the model being used through the filters
for filter_ in self.filters.values():
# propagate the model and filterset to the filters
filter_.model = self._meta.model
# Apply the parent to the filters, this will allow the filters to access the filterset
for filter_key, filter_ in six.iteritems(self.filters):
filter_.parent = self
def __iter__(self):
deprecate('QuerySet methods are no longer proxied.')
for obj in self.qs:
yield obj
def __len__(self):
deprecate('QuerySet methods are no longer proxied.')
return self.qs.count()
def __getitem__(self, key):
deprecate('QuerySet methods are no longer proxied.')
return self.qs[key]
def count(self):
deprecate('QuerySet methods are no longer proxied.')
return self.qs.count()
@property
def qs(self):
if not hasattr(self, '_qs'):
valid = self.is_bound and self.form.is_valid()
if not self.is_bound:
self._qs = self.queryset.all()
return self._qs
if self.is_bound and not valid:
if not self.form.is_valid():
if self.strict == STRICTNESS.RAISE_VALIDATION_ERROR:
raise forms.ValidationError(self.form.errors)
elif bool(self.strict) == STRICTNESS.RETURN_NO_RESULTS:
elif self.strict == STRICTNESS.RETURN_NO_RESULTS:
self._qs = self.queryset.none()
return self._qs
# else STRICTNESS.IGNORE... ignoring
@ -330,20 +203,7 @@ class BaseFilterSet(object):
# start with all the results and filter from there
qs = self.queryset.all()
for name, filter_ in six.iteritems(self.filters):
value = None
if valid:
value = self.form.cleaned_data[name]
else:
raw_value = self.form[name].value()
try:
value = self.form.fields[name].clean(raw_value)
except forms.ValidationError:
if self.strict == STRICTNESS.RAISE_VALIDATION_ERROR:
raise
elif bool(self.strict) == STRICTNESS.RETURN_NO_RESULTS:
self._qs = self.queryset.none()
return self._qs
# else STRICTNESS.IGNORE... ignoring
value = self.form.cleaned_data.get(name)
if value is not None: # valid & clean data
qs = filter_.filter(qs, value)
@ -370,62 +230,88 @@ class BaseFilterSet(object):
return self._form
@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 get_fields(cls):
"""
Resolve the 'fields' argument that should be used for generating filters on the
filterset. This is 'Meta.fields' sans the fields in 'Meta.exclude'.
"""
model = cls._meta.model
fields = cls._meta.fields
exclude = cls._meta.exclude
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)
assert not (fields is None and exclude is None), \
"Setting 'Meta.model' without either 'Meta.fields' or 'Meta.exclude' " \
"has been deprecated since 0.15.0 and is now disallowed. Add an explicit" \
"'Meta.fields' or 'Meta.exclude' to the %s class." % cls.__name__
return pretty_name(text)
# Setting exclude with no fields implies all other fields.
if exclude is not None and fields is None:
fields = ALL_FIELDS
if isinstance(opts.order_by, (list, tuple)):
# Resolve ALL_FIELDS into all fields for the filterset's model.
if fields == ALL_FIELDS:
fields = get_all_model_fields(model)
# 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)
# Remove excluded fields
exclude = exclude or []
if not isinstance(fields, dict):
fields = [(f, ['exact']) for f in fields if f not in exclude]
else:
fields = [(f, lookups) for f, lookups in fields.items() if f not in exclude]
# e.g. ('field1', 'field2', ...)
else:
# (filter name, filter instance)
order_by = [(f, filters.get(f.lstrip('-'))) for f in opts.order_by]
# 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)
# opts.order_by = True
order_by = filters.items()
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)
return OrderedDict(fields)
@classmethod
def filters_for_model(cls, model, opts):
# TODO: remove with deprecations - this emulates the old behavior
fields = opts.fields
if fields is None:
DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS)
DEFAULTS.update(opts.filter_overrides)
fields = get_all_model_fields(model, field_types=DEFAULTS.keys())
def get_filters(cls):
"""
Get all filters for the filterset. This is the combination of declared and
generated filters.
"""
return filters_for_model(
model, fields, opts.exclude,
cls.filter_for_field,
cls.filter_for_reverse_field
)
# No model specified - skip filter generation
if not cls._meta.model:
return cls.declared_filters.copy()
# Determine the filters that should be included on the filterset.
filters = OrderedDict()
fields = cls.get_fields()
undefined = []
for field_name, lookups in fields.items():
field = get_model_field(cls._meta.model, field_name)
# warn if the field doesn't exist.
if field is None:
undefined.append(field_name)
# ForeignObjectRel does not support non-exact lookups
if isinstance(field, ForeignObjectRel):
filters[field_name] = cls.filter_for_reverse_field(field, field_name)
continue
for lookup_expr in lookups:
filter_name = get_filter_name(field_name, lookup_expr)
# If the filter is explicitly declared on the class, skip generation
if filter_name in cls.declared_filters:
filters[filter_name] = cls.declared_filters[filter_name]
continue
if field is not None:
filters[filter_name] = cls.filter_for_field(field, field_name, lookup_expr)
# filter out declared filters
undefined = [f for f in undefined if f not in cls.declared_filters]
if undefined:
raise TypeError(
"'Meta.fields' contains fields that are not defined on this FilterSet: "
"%s" % ', '.join(undefined)
)
# Add in declared filters. This is necessary since we don't enforce adding
# declared filters to the 'Meta.fields' option
filters.update(cls.declared_filters)
return filters
@classmethod
def filter_for_field(cls, f, name, lookup_expr='exact'):
@ -433,8 +319,7 @@ class BaseFilterSet(object):
default = {
'name': name,
'label': capfirst(f.verbose_name),
'lookup_expr': lookup_expr
'lookup_expr': lookup_expr,
}
filter_class, params = cls.filter_for_lookup(f, lookup_type)
@ -454,7 +339,6 @@ class BaseFilterSet(object):
queryset = f.field.model._default_manager.all()
default = {
'name': name,
'label': capfirst(rel.related_name),
'queryset': queryset,
}
if rel.multiple:
@ -538,7 +422,7 @@ class FilterSet(six.with_metaclass(FilterSetMetaclass, BaseFilterSet)):
def filterset_factory(model):
meta = type(str('Meta'), (object,), {'model': model, 'fields': '__all__'})
meta = type(str('Meta'), (object,), {'model': model, 'fields': ALL_FIELDS})
filterset = type(str('%sFilterSet' % model._meta.object_name),
(FilterSet,), {'Meta': meta})
return filterset

Binary file not shown.

View File

@ -0,0 +1,48 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django-filter\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-08-10 05:34-0500\n"
"PO-Revision-Date: 2016-09-29 11:47+0300\n"
"Last-Translator: Mikhail Mitrofanov <mm@elec.ru>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: Poedit 1.8.9\n"
#: filters.py:153
msgid "Any date"
msgstr "Любая дата"
#: filters.py:154
msgid "Today"
msgstr "Сегодня"
#: filters.py:159
msgid "Past 7 days"
msgstr "Прошедшие 7 дней"
#: filters.py:163
msgid "This month"
msgstr "За этот месяц"
#: filters.py:167
msgid "This year"
msgstr "В этом году"
#: filterset.py:332 filterset.py:341
#, python-format
msgid "%s (descending)"
msgstr "%s (по убыванию)"
#: widgets.py:63
msgid "All"
msgstr "Все"

View File

@ -2,6 +2,7 @@
from __future__ import absolute_import
from django.template import Template, TemplateDoesNotExist, loader
from django.utils import six
from rest_framework.compat import template_render
from rest_framework.filters import BaseFilterBackend
@ -71,7 +72,7 @@ class DjangoFilterBackend(BaseFilterBackend):
filter_class = self.get_filter_class(view, queryset)
if filter_class:
return filter_class(request.query_params, queryset=queryset).qs
return filter_class(request.query_params, queryset=queryset, request=request).qs
return queryset
@ -79,7 +80,7 @@ class DjangoFilterBackend(BaseFilterBackend):
filter_class = self.get_filter_class(view, queryset)
if not filter_class:
return None
filter_instance = filter_class(request.query_params, queryset=queryset)
filter_instance = filter_class(request.query_params, queryset=queryset, request=request)
try:
template = loader.get_template(self.template)
@ -98,6 +99,7 @@ class DjangoFilterBackend(BaseFilterBackend):
filter_class = self.get_filter_class(view, view.get_queryset())
return [] if not filter_class else [
compat.coreapi.Field(name=field_name, required=False, location='query')
for field_name in filter_class().filters.keys()
compat.coreapi.Field(
name=field_name, required=False, location='query', description=six.text_type(field.field.help_text))
for field_name, field in filter_class().filters.items()
]

View File

@ -6,8 +6,10 @@ from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.expressions import Expression
from django.db.models.fields import FieldDoesNotExist
from django.db.models.fields.related import ForeignObjectRel
from django.db.models.fields.related import RelatedField, ForeignObjectRel
from django.utils import six, timezone
from django.utils.encoding import force_text
from django.utils.translation import ugettext as _
try:
from django.forms.utils import pretty_name
@ -44,18 +46,9 @@ def try_dbfield(fn, field_class):
return data
# TODO: remove field_types arg with deprecations
def get_all_model_fields(model, field_types=None):
def get_all_model_fields(model):
opts = model._meta
if field_types is not None:
return [
f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField) and
not (getattr(remote_field(f), 'parent_link', False)) and
f.__class__ in field_types
]
return [
f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField) and
@ -72,25 +65,41 @@ def get_model_field(model, field_name):
f = get_model_field(Book, 'author__first_name')
"""
fields = get_field_parts(model, field_name)
return fields[-1] if fields else None
def get_field_parts(model, field_name):
"""
Get the field parts that represent the traversable relationships from the
base ``model`` to the final field, described by ``field_name``.
ex::
>>> parts = get_field_parts(Book, 'author__first_name')
>>> [p.verbose_name for p in parts]
['author', 'first name']
"""
parts = field_name.split(LOOKUP_SEP)
opts = model._meta
fields = []
# walk relationships
for name in parts[:-1]:
for name in parts:
try:
rel = opts.get_field(name)
field = opts.get_field(name)
except FieldDoesNotExist:
return None
if isinstance(rel, ForeignObjectRel):
opts = rel.related_model._meta
else:
opts = remote_model(rel)._meta
try:
return opts.get_field(parts[-1])
except FieldDoesNotExist:
return None
fields.append(field)
if isinstance(field, RelatedField):
opts = remote_model(field)._meta
elif isinstance(field, ForeignObjectRel):
opts = field.related_model._meta
return fields
def resolve_field(model_field, lookup_expr):
@ -139,3 +148,82 @@ def handle_timezone(value):
elif not settings.USE_TZ and timezone.is_aware(value):
return timezone.make_naive(value, timezone.UTC())
return value
def verbose_field_name(model, field_name):
"""
Get the verbose name for a given ``field_name``. The ``field_name``
will be traversed across relationships. Returns '[invalid name]' for
any field name that cannot be traversed.
ex::
>>> verbose_field_name(Article, 'author__name')
'author name'
"""
if field_name is None:
return '[invalid name]'
parts = get_field_parts(model, field_name)
if not parts:
return '[invalid name]'
names = []
for part in parts:
if isinstance(part, ForeignObjectRel):
names.append(force_text(part.related_name))
else:
names.append(force_text(part.verbose_name))
return ' '.join(names)
def verbose_lookup_expr(lookup_expr):
"""
Get a verbose, more humanized expression for a given ``lookup_expr``.
Each part in the expression is looked up in the ``FILTERS_VERBOSE_LOOKUPS``
dictionary. Missing keys will simply default to itself.
ex::
>>> verbose_lookup_expr('year__lt')
'year is less than'
# with `FILTERS_VERBOSE_LOOKUPS = {}`
>>> verbose_lookup_expr('year__lt')
'year lt'
"""
from .conf import settings as app_settings
VERBOSE_LOOKUPS = app_settings.VERBOSE_LOOKUPS or {}
lookups = [
force_text(VERBOSE_LOOKUPS.get(lookup, _(lookup)))
for lookup in lookup_expr.split(LOOKUP_SEP)
]
return ' '.join(lookups)
def label_for_filter(model, field_name, lookup_expr, exclude=False):
"""
Create a generic label suitable for a filter.
ex::
>>> label_for_filter(Article, 'author__name', 'in')
'auther name is in'
"""
name = verbose_field_name(model, field_name)
verbose_expression = [_('exclude'), name] if exclude else [name]
# iterable lookups indicate a LookupTypeField, which should not be verbose
if isinstance(lookup_expr, six.string_types):
verbose_expression += [verbose_lookup_expr(lookup_expr)]
verbose_expression = [force_text(part) for part in verbose_expression if part]
verbose_expression = pretty_name(' '.join(verbose_expression))
return verbose_expression

View File

@ -36,7 +36,10 @@ class FilterMixin(object):
"""
Returns the keyword arguments for instanciating the filterset.
"""
kwargs = {'data': self.request.GET or None}
kwargs = {
'data': self.request.GET or None,
'request': self.request,
}
try:
kwargs.update({
'queryset': self.get_queryset(),

View File

@ -11,6 +11,7 @@ except:
from django import forms
from django.db.models.fields import BLANK_CHOICE_DASH
from django.forms.widgets import flatatt
from django.utils.datastructures import MultiValueDict
from django.utils.encoding import force_text
from django.utils.safestring import mark_safe
from django.utils.six import string_types
@ -171,3 +172,38 @@ class BaseCSVWidget(forms.Widget):
class CSVWidget(BaseCSVWidget, forms.TextInput):
pass
class QueryArrayWidget(BaseCSVWidget, forms.TextInput):
"""
Enables request query array notation that might be consumed by MultipleChoiceFilter
1. Values can be provided as csv string: ?foo=bar,baz
2. Values can be provided as query array: ?foo[]=bar&foo[]=baz
3. Values can be provided as query array: ?foo=bar&foo=baz
Note: Duplicate and empty values are skipped from results
"""
def value_from_datadict(self, data, files, name):
if not isinstance(data, MultiValueDict):
data = MultiValueDict(data)
values_list = data.getlist(name, data.getlist('%s[]' % name)) or []
if isinstance(values_list, string_types):
values_list = [values_list]
# apparently its an array, so no need to process it's values as csv
# ?foo=1&foo=2 -> data.getlist(foo) -> foo = [1, 2]
# ?foo[]=1&foo[]=2 -> data.getlist(foo[]) -> foo = [1, 2]
if len(values_list) > 1:
ret = [x for x in values_list if x]
elif len(values_list) == 1:
# treat first element as csv string
# ?foo=1,2 -> data.getlist(foo) -> foo = ['1,2']
ret = [x.strip() for x in values_list[0].rstrip(',').split(',') if x]
else:
ret = []
return list(set(ret))

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.'
# built documents.
#
# The short X.Y version.
version = '0.15.3'
version = '1.0.0'
# The full version, including alpha/beta/rc tags.
release = '0.15.3'
release = '1.0.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

67
docs/dev/tests.txt Normal file
View File

@ -0,0 +1,67 @@
======================
Running the Test Suite
======================
The easiest way to run the django-filter tests is to check out the source
code and create a virtualenv where you can install the test dependencies.
Django-filter uses a custom test runner to configure the environment, so a
wrapper script is available to set up and run the test suite.
.. note::
The following assumes you have `virtualenv`__ and `git`__ installed.
__ https://virtualenv.pypa.io/en/stable/
__ https://git-scm.com
Clone the repository
--------------------
Get the source code using the following command:
.. code-block:: bash
$ git clone https://github.com/carltongibson/django-filter.git
Switch to the django-filter directory:
.. code-block:: bash
$ cd django-filter
Set up the virtualenv
---------------------
Create a new virtualenv to run the test suite in:
.. code-block:: bash
$ virtualenv venv
Then activate the virtualenv and install the test requirements:
.. code-block:: bash
$ source venv/bin/activate
$ pip install -r requirements/test.txt
Execute the test runner
-----------------------
Run the tests with the runner script:
.. code-block:: bash
$ python runtests.py
Test 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, and then simply run:
.. code-block:: bash
$ pip install tox
$ tox

33
docs/guide/install.txt Normal file
View File

@ -0,0 +1,33 @@
============
Installation
============
Django-filter can be installed from PyPI with tools like ``pip``:
.. code-block:: bash
$ pip install django-filter
Then add ``'django_filters'`` to your ``INSTALLED_APPS``.
.. note::
django-filter provides *some* localization for *some* languages. If you do
not need these translations (or would rather provide your own), then it is
unnecessary to add django-filter to the ``INSTALLED_APPS`` setting.
Requirements
------------
Django-filter is tested against all supported versions of Python and `Django`__,
as well as the latest versions of Django REST Framework (`DRF`__).
__ https://www.djangoproject.com/download/
__ http://www.django-rest-framework.org/
* **Python**: 2.7, 3.3, 3.4, 3.5
* **Django**: 1.8, 1.9, 1.10
* **DRF**: 3.4, 3.5

View File

@ -1,3 +1,4 @@
================
Migrating to 1.0
================

View File

@ -1,5 +1,6 @@
Django Rest Framework
=====================
====================
Integration with DRF
====================
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.
@ -72,8 +73,8 @@ To enable filtering with a ``FilterSet``, add it to the ``filter_class`` paramet
filter_class = ProductFilter
Specifying ``filter_fields``
----------------------------
Using the ``filter_fields`` shortcut
------------------------------------
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 <fields>`.
@ -98,14 +99,74 @@ You may bypass creating a ``FilterSet`` by instead adding ``filter_fields`` to y
fields = ('category', 'in_stock')
Schema Generation with Core API
-------------------------------
The backend class integrates with DRF's schema generation by implementing ``get_schema_fields()``. This is automatically enabled when Core API is installed. Schema generation usually functions seamlessly, however the implementation does expect to invoke the view's ``get_queryset()`` method. There is a caveat in that views are artificially constructed during schema generation, so the ``args`` and ``kwargs`` attributes will be empty. If you depend on arguments parsed from the URL, you will need to handle their absence in ``get_queryset()``.
For example, your get queryset method may look like this:
.. code-block:: python
class IssueViewSet(views.ModelViewSet):
queryset = models.Issue.objects.all()
def get_project(self):
return models.Project.objects.get(pk=self.kwargs['project_id'])
def get_queryset(self):
project = self.get_project()
return self.queryset \
.filter(project=project) \
.filter(author=self.request.user)
This could be rewritten like so:
.. code-block:: python
class IssueViewSet(views.ModelViewSet):
queryset = models.Issue.objects.all()
def get_project(self):
try:
return models.Project.objects.get(pk=self.kwargs['project_id'])
except models.Project.DoesNotExist:
return None
def get_queryset(self):
project = self.get_project()
if project is None:
return self.queryset.none()
return self.queryset \
.filter(project=project) \
.filter(author=self.request.user)
Or more simply as:
.. code-block:: python
class IssueViewSet(views.ModelViewSet):
queryset = models.Issue.objects.all()
def get_queryset(self):
# project_id may be None
return self.queryset \
.filter(project_id=self.kwargs.get('project_id')) \
.filter(author=self.request.user)
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.
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:
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
.. image:: ../assets/form.png

215
docs/guide/tips.txt Normal file
View File

@ -0,0 +1,215 @@
==================
Tips and Solutions
==================
Common problems for declared filters
------------------------------------
Below are some of the common problem that occur when declaring filters. It is
recommended that you read this as it provides a more complete understanding of
how filters work.
Filter ``name`` and ``lookup_expr`` not configured
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
While ``name`` and ``lookup_expr`` are optional, it is recommended that you specify
them. By default, if ``name`` is not specified, the filter's name on the
filterset class will be used. Additionally, ``lookup_expr`` defaults to
``exact``. The following is an example of a misconfigured price filter:
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter()
The filter instance will have a field name of ``price__gt`` and an ``exact``
lookup type. Under the hood, this will incorrectly be resolved as:
.. code-block:: python
Produce.objects.filter(price__gt__exact=value)
The above will most likely generate a ``FieldError``. The correct configuration
would be:
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt')
Missing ``lookup_expr`` for text search filters
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It's quite common to forget to set the lookup expression for :code:`CharField`
and :code:`TextField` and wonder why a search for "foo" does not return results
for "foobar". This is because the default lookup type is ``exact``, but you
probably want to perform an ``icontains`` lookup.
Filter and lookup expression mismatch (in, range, isnull)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It's not always appropriate to directly match a filter to its model field's
type, as some lookups expect different types of values. This is a commonly
found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look
at the following product model:
.. code-block:: python
class Product(models.Model):
category = models.ForeignKey(Category, null=True)
Given that ``category`` is optional, it's reasonable to want to enable a search
for uncategorized products. The following is an incorrectly configured
``isnull`` filter:
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
uncategorized = django_filters.NumberFilter(name='category', lookup_expr='isnull')
So what's the issue? While the underlying column type for ``category`` is an
integer, ``isnull`` lookups expect a boolean value. A ``NumberFilter`` however
only validates numbers. Filters are not `'expression aware'` and won't change
behavior based on their ``lookup_expr``. You should use filters that match the
data type of the lookup expression `instead` of the data type underlying the
model field. The following would correctly allow you to search for both
uncategorized products and products for a set of categories:
.. code-block:: python
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
class ProductFilter(django_filters.FilterSet):
categories = NumberInFilter(name='category', lookup_expr='in')
uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull')
More info on constructing ``in`` and ``range`` csv :ref:`filters <base-in-filter>`.
Filtering by empty values
-------------------------
There are a number of cases where you may need to filter by empty or null
values. The following are some common solutions to these problems:
Filtering by null values
~~~~~~~~~~~~~~~~~~~~~~~~
As explained in the above "Filter and lookup expression mismatch" section, a
common problem is how to correctly filter by null values on a field.
Solution 1: Using a ``BooleanFilter`` with ``isnull``
"""""""""""""""""""""""""""""""""""""""""""""""""""""
Using ``BooleanFilter`` with an ``isnull`` lookup is a builtin solution used by
the FilterSet's automatic filter generation. To do this manually, simply add:
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull')
.. note::
Remember that the filter class is validating the input value. The underlying
type of the mode field is not relevant here.
You may also reverse the logic with the ``exclude`` parameter.
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
has_category = django_filters.BooleanFilter(name='category', lookup_expr='isnull', exclude=True)
Solution 2: Using ``ChoiceFilter``'s null choice
""""""""""""""""""""""""""""""""""""""""""""""""
If you're using a ChoiceFilter, you may also filter by null values by enabling
the ``null_label`` parameter. More details in the ``ChoiceFilter`` reference
:ref:`docs <choice-filter>`.
.. code-block:: python
class ProductFilter(django_filters.FilterSet):
category = django_filters.ModelChoiceFilter(
name='category', lookup_expr='isnull',
null_label='Uncategorized',
queryset=Category.objects.all(),
)
Solution 3: Combining fields w/ ``MultiValueField``
""""""""""""""""""""""""""""""""""""""""""""""""""
An alternative approach is to use Django's ``MultiValueField`` to manually add
in a ``BooleanField`` to handle null values. Proof of concept:
https://github.com/carltongibson/django-filter/issues/446
Filtering by an empty string
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It's not currently possible to filter by an empty string, since empty values are
interpreted as a skipped filter.
.. code-block:: http
GET http://localhost/api/my-model?myfield=
Solution 1: Magic values
""""""""""""""""""""""""
You can override the ``filter()`` method of a filter class to specifically check
for magic values. This is similar to the ``ChoiceFilter``'s null value handling.
.. code-block:: http
GET http://localhost/api/my-model?myfield=EMPTY
.. code-block:: python
class MyCharFilter(filters.CharFilter):
empty_value = 'EMPTY'
def filter(self, qs, value):
if value != self.empty_value:
return super(MyCharFilter, self).filter(qs, value)
qs = self.get_method(qs)(**{'%s__%s' % (self.name, self.lookup_expr): ""})
return qs.distinct() if self.distinct else qs
Solution 2: Empty string filter
"""""""""""""""""""""""""""""""
It would also be possible to create an empty value filter that exhibits the same
behavior as an ``isnull`` filter.
.. code-block:: http
GET http://localhost/api/my-model?myfield__isempty=false
.. code-block:: python
from django.core.validators import EMPTY_VALUES
class EmptyStringFilter(filters.BooleanFilter):
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
exclude = self.exclude ^ (value is False)
method = qs.exclude if exclude else qs.filter
return method(**{self.name: ""})
class MyFilterSet(filters.FilterSet):
myfield__isempty = EmptyStringFilter(name='myfield')
class Meta:
model = MyModel

View File

@ -1,5 +1,6 @@
Using django-filter
===================
===============
Getting Started
===============
Django-filter provides a simple way to filter down a queryset based on
parameters a user provides. Say we have a ``Product`` model and we want to let
@ -80,82 +81,6 @@ For Django version 1.8, transformed expressions are not supported.
.. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups
Common declarative problems
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Below are some of the common problem that occur when declaring filters. It is
recommended that you do read this as it provides a more complete understanding
on how filters work.
Filter ``name`` and ``lookup_expr`` not configured
""""""""""""""""""""""""""""""""""""""""""""""""""
While ``name`` and ``lookup_expr`` are optional, it is recommended that you specify
them. By default, if ``name`` is not specified, the filter's name on the
filterset class will be used. Additionally, ``lookup_expr`` defaults to
``exact``. The following is an example of a misconfigured price filter::
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter()
The filter instance will have a field name of ``price__gt`` and an ``exact``
lookup type. Under the hood, this will incorrectly be resolved as::
Produce.objects.filter(price__gt__exact=value)
The above will most likely generate a ``FieldError``. The correct configuration
would be::
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt')
Missing ``lookup_expr`` for text search filters
"""""""""""""""""""""""""""""""""""""""""""""""
It's quite common to forget to set the lookup expression for :code:`CharField`
and :code:`TextField` and wonder why a search for "foo" does not return results
for "foobar". This is because the default lookup type is ``exact``, but you
probably want to perform an ``icontains`` lookup.
Filter and lookup expression mismatch (in, range, isnull)
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
It's not always appropriate to directly match a filter to its model field's
type, as some lookups expect different types of values. This is a commonly
found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look
at the following product model::
class Product(models.Model):
category = models.ForeignKey(Category, null=True)
Given that ``category`` is optional, it's reasonable to want to enable a search
for uncategorized products. The following is an incorrectly configured
``isnull`` filter::
class ProductFilter(django_filters.FilterSet):
uncategorized = django_filters.NumberFilter(name='category', lookup_expr='isnull')
So what's the issue? While the underlying column type for ``category`` is an
integer, ``isnull`` lookups expect a boolean value. A ``NumberFilter`` however
only validates numbers. Filters are not `'expression aware'` and won't change
behavior based on their ``lookup_expr``. You should use filters that match the
data type of the lookup expression `instead` of the data type underlying model
field. The following would correctly allow you to search for both uncategorized
products and products for a set of categories::
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
class ProductFilter(django_filters.FilterSet):
categories = NumberInFilter(name='category', lookup_expr='in')
uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull')
More info on constructing IN and RANGE csv :ref:`filters <base-in-filter>`.
Generating filters with Meta.fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -223,7 +148,7 @@ default filters for all the models fields of the same kind using
models.BooleanField: {
'filter_class': django_filters.BooleanFilter,
'extra': lambda f: {
'widget': 'forms.CheckboxInput',
'widget': forms.CheckboxInput,
},
},
}
@ -236,11 +161,63 @@ default filters for all the models fields of the same kind using
}
Request-based filtering
~~~~~~~~~~~~~~~~~~~~~~~
The ``FilterSet`` may be initialized with an optional ``request`` argument. If
a request object is passed, then you may access the request during filtering.
This allows you to filter by properties on the request, such as the currently
logged-in user or the ``Accepts-Languages`` header.
Filtering the primary ``.qs``
"""""""""""""""""""""""""""""
To filter the primary queryset by the ``request`` object, simply override the
``FilterSet.qs`` property. For example, you could filter blog articles to only
those that are published and those that are owned by the logged-in user
(presumably the author's draft articles).
.. code-block:: python
class ArticleFilter(django_filters.FilterSet):
class Meta:
model = Article
fields = [...]
@property
def qs(self):
parent = super(ArticleFilter, self).qs
return parent.filter(is_published=True) \
| parent.filter(author=request.user)
Filtering the related queryset for ``ModelChoiceFilter``
""""""""""""""""""""""""""""""""""""""""""""""""""""""""
The ``queryset`` argument for ``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter``
supports callable behavior. If a callable is passed, it will be invoked with the
``request`` as its only argument. This allows you to perform the same kinds of
request-based filtering without resorting to overriding ``FilterSet.__init__``.
.. code-block:: python
def departments(request):
company = request.user.company
return company.department_set.all()
class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...
Customize filtering with ``Filter.method``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can control the behavior of a filter by specifying a ``method`` to perform
filtering. View more information in the :ref:`method reference <filter-method>`.
Note that you may access the filterset's properties, such as the ``request``.
.. code-block:: python

View File

@ -7,18 +7,28 @@ the more mundane bits of view code. Specifically, it allows users to filter
down a queryset based on a model's fields, displaying the form to let them
do this.
Contents:
.. toctree::
:maxdepth: 2
:caption: User Guide
guide/install
guide/usage
guide/rest_framework
guide/tips
guide/migration
.. toctree::
:maxdepth: 1
:caption: Reference Documentation
install
usage
rest_framework
ref/filterset
ref/filters
ref/fields
ref/widgets
ref/settings
migration
tests
.. toctree::
:maxdepth: 1
:caption: Developer Documentation
dev/tests

View File

@ -1,10 +0,0 @@
Installing django-filter
------------------------
Install with pip:
.. code-block:: bash
pip install django-filter
And then add ``'django_filters'`` to your ``INSTALLED_APPS``.

View File

@ -1,5 +1,6 @@
Fields Reference
================
===============
Field Reference
===============
``IsoDateTimeField``
~~~~~~~~~~~~~~~~~~~~
@ -10,7 +11,9 @@ Defines a class level attribute ``ISO_8601`` as constant for the format.
Sets ``input_formats = [ISO_8601]`` — this means that by default ``IsoDateTimeField`` will **only** parse ISO 8601 formated dates.
You may set ``input_formats`` to your list of required formats as per the `DateTimeField Docs`_, using the ``ISO_8601`` class level attribute to specify the ISO 8601 format. ::
You may set ``input_formats`` to your list of required formats as per the `DateTimeField Docs`_, using the ``ISO_8601`` class level attribute to specify the ISO 8601 format.
.. code-block:: python
f = IsoDateTimeField()
f.input_formats = [IsoDateTimeField.ISO_8601] + DateTimeField.input_formats

View File

@ -1,3 +1,4 @@
================
Filter Reference
================
@ -22,7 +23,9 @@ fields on a related model. eg, ``manufacturer__name``.
~~~~~~~~~
The label as it will apear in the HTML, analogous to a form field's label
argument.
argument. If a label is not provided, a verbose label will be generated based
on the field ``name`` and the parts of the ``lookup_expr``.
(See: :ref:`verbose-lookups-setting`).
``widget``
~~~~~~~~~~
@ -189,14 +192,15 @@ This filter matches UUID values, used with ``models.UUIDField`` by default.
This filter matches a boolean, either ``True`` or ``False``, used with
``BooleanField`` and ``NullBooleanField`` by default.
.. _choice-filter:
``ChoiceFilter``
~~~~~~~~~~~~~~~~
This filter matches an item of any type by choices, used with any field that
has ``choices``.
This filter matches values in its ``choices`` argument. The ``choices`` must be
explicitly passed when the filter is declared on the ``FilterSet``. For example,
Requires ``choices`` ``kwarg`` to be passed if explicitly declared on the ``FilterSet``. For example::
.. code-block:: python
class User(models.Model):
username = models.CharField(max_length=255)
@ -218,6 +222,20 @@ Requires ``choices`` ``kwarg`` to be passed if explicitly declared on the ``Filt
fields = ['status']
``ChoiceFilter`` also has arguments that enable a choice for not filtering, as
well as a choice for filtering by ``None`` values. Each of the arguments have a
corresponding global setting (:doc:`/ref/settings`).
* ``empty_label``: The display label to use for the select choice to not filter.
The choice may be disabled by setting this argument to ``None``. Defaults to
``FILTERS_EMPTY_CHOICE_LABEL``.
* ``null_label``: The display label to use for the choice to filter by ``None``
values. The choice may be disabled by setting this argument to ``None``.
Defaults to ``FILTERS_NULL_CHOICE_LABEL``.
* ``null_value``: The special value to match to enable filtering by ``None``
values. This value defaults ``FILTERS_NULL_CHOICE_VALUE`` and needs to be
a non-empty value (``''``, ``None``, ``[]``, ``()``, ``{}``).
``TypedChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~
@ -249,6 +267,8 @@ argument is passed to this class.
Multiple choices are represented in the query string by reusing the same key with
different values (e.g. ''?status=Regular&status=Admin'').
``distinct`` defaults to ``True`` as to-many relationships will generally require this.
Advanced Use: 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 of the `distinct` call.
@ -259,6 +279,13 @@ test.
Override `is_noop` if you require a different test for your application.
``TypedMultipleChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Like ``MultipleChoiceFilter``, but in addition accepts the ``coerce`` parameter, as
in ``TypedChoiceFilter``.
``DateFilter``
~~~~~~~~~~~~~~
@ -280,7 +307,8 @@ Matches on a date and time. Used with ``DateTimeField`` by default.
``IsoDateTimeFilter``
~~~~~~~~~~~~~~~~~~~~~
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.
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::
@ -323,6 +351,22 @@ Example::
model = Book
fields = ['author']
The ``queryset`` argument also supports callable behavior. If a callable is
passed, it will be invoked with ``Filterset.request`` as its only argument.
This allows you to easily filter by properties on the request object without
having to override the ``FilterSet.__init__``.
.. code-block:: python
def departments(request):
company = request.user.company
return company.department_set.all()
class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...
``ModelMultipleChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -332,6 +376,7 @@ 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.
Like ``ModelChoiceFilter``, the ``queryset`` argument has callable behavior.
To use a custom field name for the lookup, you can use ``to_field_name``::
@ -389,15 +434,18 @@ and ``DecimalField`` by default.
~~~~~~~~~~~~~~~~~~~~~~
Filters where a value is between two numerical values, or greater than a minimum or less
than a maximum where only one limit value is provided. This filter is designed to work with
the Postgres Numerical Range Fields, including `IntegerRangeField`, `BigIntegerRangeField` and `FloatRangeField`,
available since Django 1.8. The default widget used is the `RangeField`.
than a maximum where only one limit value is provided. This filter is designed to work
with the Postgres Numerical Range Fields, including ``IntegerRangeField``,
``BigIntegerRangeField`` and ``FloatRangeField`` (available since Django 1.8). The default
widget used is the ``RangeField``.
RangeField lookup_exprs can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be
found in the Django docs (https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields).
Regular field lookups are available in addition to several containment lookups, including
``overlap``, ``contains``, and ``contained_by``. More details in the Django `docs`__.
If the lower limit value is provided, the filter automatically defaults to `__startswith` as the lookup
type and if only the upper limit value is provided, the filter uses `__endswith`.
__ https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields
If the lower limit value is provided, the filter automatically defaults to ``startswith``
as the lookup and ``endswith`` if only the upper limit value is provided.
``RangeFilter``
~~~~~~~~~~~~~~~
@ -619,29 +667,6 @@ Example::
assert len(f.qs) == 3
``MethodFilter``
~~~~~~~~~~~~~~~~
This is a ``Filter`` that will allow you to run a method that exists on the filter set that
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::
class F(FilterSet):
"""Filter for Books by if books are published or not"""
published = MethodFilter()
def filter_published(self, queryset, value):
if value:
return queryset.filter(published__isnull=False)
return queryset
class Meta:
model = Book
fields = ['published']
.. _ordering-filter:
``OrderingFilter``
@ -718,3 +743,28 @@ 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.
Adding Custom filter choices
""""""""""""""""""""""""""""
If you wish to sort by non-model fields, you'll need to add custom handling to an
``OrderingFilter`` subclass. For example, if you want to sort by a computed
'relevance' factor, you would need to do something like the following:
.. code-block:: python
class CustomOrderingFilter(django_filters.OrderingFilter):
def __init__(self, *args, **kwargs):
super(CustomOrderingFilter, self).__init__(*args, **kwargs)
self.choices += [
('relevance', 'Relevance'),
('-relevance', 'Relevance (descending)'),
]
def filter(self, qs, value):
if value in ['relevance', '-relevance']:
# sort queryset by relevance
return ...
return super(CustomOrderingFilter, self).filter(qs, value)

View File

@ -1,5 +1,6 @@
FilterSet Guide
===============
=================
FilterSet Options
=================
This document provides a guide on using additional FilterSet features.
@ -9,7 +10,6 @@ Meta options
- :ref:`model <model>`
- :ref:`fields <fields>`
- :ref:`exclude <exclude>`
- :ref:`order_by <order-by>`
- :ref:`form <form>`
- :ref:`together <together>`
- filter_overrides
@ -91,40 +91,6 @@ declared directly on the ``FilterSet``.
exclude = ['password']
.. _order-by:
Ordering using ``order_by``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can allow the user to control ordering by providing the
``order_by`` argument in the Filter's Meta class. ``order_by`` can be either a
``list`` or ``tuple`` of field names, in which case those are the options, or
it can be a ``bool`` which, if True, indicates that all fields that
the user can filter on can also be sorted on. An example of ordering using a list::
import django_filters
class ProductFilter(django_filters.FilterSet):
price = django_filters.NumberFilter(lookup_expr='lt')
class Meta:
model = Product
fields = ['price', 'release_date']
order_by = ['price']
If you want to control the display of items in ``order_by``, you can set it to
a list or tuple of 2-tuples in the format ``(field_name, display_name)``.
This lets you override the displayed names for your ordering fields::
order_by = (
('name', 'Company Name'),
('average_rating', 'Stars'),
)
Note that the default query parameter name used for ordering is ``o``. You
can override this by setting an ``order_by_field`` attribute on the
``FilterSet``'s Meta class to the string value you would like to use.
.. _form:
Custom Forms using ``form``
@ -201,29 +167,3 @@ filters for a model field, you can override ``filter_for_lookup()``. Ex::
# use default behavior otherwise
return super(ProductFilter, cls).filter_for_lookup(f, lookup_type)
``get_ordering_field()``
~~~~~~~~~~~~~~~~~~~~~~~~
If you want to use a custom widget, or in any other way override the ordering
field you can override the ``get_ordering_field()`` method on a ``FilterSet``.
This method just needs to return a Form Field.
Ordering on multiple fields, or other complex orderings can be achieved by
overriding the ``FilterSet.get_order_by()`` method. This is passed the selected
``order_by`` value, and is expected to return an iterable of values to pass to
``QuerySet.order_by``. For example, to sort a ``User`` table by last name, then
first name::
class UserFilter(django_filters.FilterSet):
class Meta:
order_by = (
('username', 'Username'),
('last_name', 'Last Name')
)
def get_order_by(self, order_value):
if order_value == 'last_name':
return ['last_name', 'first_name']
return super(UserFilter, self).get_order_by(order_value)

View File

@ -1,7 +1,5 @@
.. _ref-settings:
==================
Available Settings
Settings Reference
==================
Here is a list of all available settings of django-filters and their
@ -9,20 +7,91 @@ default values. All settings are prefixed with ``FILTERS_``, although this
is a bit verbose it helps to make it easy to identify these settings.
FILTERS_HELP_TEXT_EXCLUDE
FILTERS_EMPTY_CHOICE_LABEL
--------------------------
Default: ``'---------'``
Set the default value for ``ChoiceFilter.empty_label``. You may disable the empty choice by setting this to ``None``.
FILTERS_NULL_CHOICE_LABEL
-------------------------
Default: ``True``
Default: ``None``
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``.
Set the default value for ``ChoiceFilter.null_label``. You may enable the null choice by setting a non-``None`` value.
.. note:: This setting is pending deprecation.
FILTERS_HELP_TEXT_FILTER
------------------------
FILTERS_NULL_CHOICE_VALUE
-------------------------
Default: ``True``
Default: ``'null'``
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``.
Set the default value for ``ChoiceFilter.null_value``. You may want to change this value if the default ``'null'`` string conflicts with an actual choice.
.. note:: This setting is pending deprecation.
FILTERS_DISABLE_HELP_TEXT
-------------------------
Default: ``False``
Some filters provide informational ``help_text``. For example, csv-based
filters (``filters.BaseCSVFilter``) inform users that "Multiple values may
be separated by commas".
You may set this to ``True`` to disable the ``help_text`` for **all**
filters, removing the text from the rendered form's output.
.. _verbose-lookups-setting:
FILTERS_VERBOSE_LOOKUPS
-----------------------
.. note::
This is considered an advanced setting and is subject to change.
Default:
.. code-block:: python
# refer to 'django_filters.conf.DEFAULTS'
'VERBOSE_LOOKUPS': {
'exact': _(''),
'iexact': _(''),
'contains': _('contains'),
'icontains': _('contains'),
...
}
This setting controls the verbose output for generated filter labels. Instead
of getting expression parts such as "lt" and "contained_by", the verbose label
would contain "is less than" and "is contained by". Verbose output may be
disabled by setting this to a falsy value.
This setting also accepts callables. The callable should not require arguments
and should return a dictionary. This is useful for extending or overriding the
default terms without having to copy the entire set of terms to your settings.
For example, you could add verbose output for "exact" lookups.
.. code-block:: python
# settings.py
def FILTERS_VERBOSE_LOOKUPS():
from django_filters.conf import DEFAULTS
verbose_lookups = DEFAULTS['VERBOSE_LOOKUPS'].copy()
verbose_lookups.update({
'exact': 'is equal to',
})
return verbose_lookups
FILTERS_STRICTNESS
------------------
Default: ``STRICTNESS.RETURN_NO_RESULTS``
Set the global default for FilterSet :ref:`strictness <strict>`.

View File

@ -1,3 +1,4 @@
================
Widget Reference
================
@ -29,7 +30,9 @@ placeholders:
This widget converts its input into Python's True/False values. It will convert
all case variations of ``True`` and ``False`` into the internal Python values.
To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``::
To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``:
.. code-block:: python
active = BooleanFilter(widget=BooleanWidget())
@ -53,6 +56,8 @@ This widget is used with ``RangeFilter`` and its subclasses. It generates two
form input elements which generally act as start/end values in a range.
Under the hood, it is django's ``forms.TextInput`` widget and excepts
the same arguments and values. To use it, pass it to ``widget`` argument of
a ``RangeField``::
a ``RangeField``:
.. code-block:: python
date_range = DateFromToRangeFilter(widget=RangeWidget(attrs={'placeholder': 'YYYY/MM/DD'}))

View File

@ -1,68 +0,0 @@
Running the django-filter tests
===============================
The easiest way to run the django-filter tests is to check out the source
code into a virtualenv, where you can install the test dependencies.
django-filter uses a custom test runner to locate all of the tests, so a
wrapper script is available to set up and run the test suite.
.. note::
The following assumes you have `virtualenv`__ and `git`__ installed.
__ http://www.virtualenv.org
__ http://git-scm.com
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
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
-----------------------------
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
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

View File

@ -6,7 +6,7 @@ f = open('README.rst')
readme = f.read()
f.close()
version = '0.15.3'
version = '1.0.0'
if sys.argv[-1] == 'publish':
if os.system("pip freeze | grep wheel"):

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django import forms
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
REGULAR = 0
@ -45,7 +46,7 @@ class SubnetMaskField(models.Field):
@python_2_unicode_compatible
class User(models.Model):
username = models.CharField(max_length=255)
username = models.CharField(_('username'), max_length=255)
first_name = SubCharField(max_length=100)
last_name = SubSubCharField(max_length=100)
@ -95,6 +96,7 @@ class Comment(models.Model):
class Article(models.Model):
name = models.CharField(verbose_name='title', max_length=200, blank=True)
published = models.DateTimeField()
author = models.ForeignKey(User, null=True, on_delete=models.CASCADE)

View File

@ -336,12 +336,10 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
<p>
<label for="id_decimal">Decimal:</label>
<input id="id_decimal" name="decimal" step="any" type="number" />
<span class="helptext">Filter</span>
</p>
<p>
<label for="id_date">Date:</label>
<input id="id_date" name="date" type="text" />
<span class="helptext">Filter</span>
</p>
<button type="submit" class="btn btn-primary">Submit</button>
</form>

View File

@ -10,6 +10,7 @@ INSTALLED_APPS = (
'django.contrib.staticfiles',
'django.contrib.auth',
'rest_framework',
'django_filters',
'tests.rest_framework',
'tests',
)

View File

@ -2,20 +2,77 @@
from django.test import TestCase, override_settings
from django_filters.conf import settings
from django_filters import FilterSet, STRICTNESS
from tests.models import User
class DefaultSettingsTests(TestCase):
def test_verbose_loookups(self):
def test_verbose_lookups(self):
self.assertIsInstance(settings.VERBOSE_LOOKUPS, dict)
self.assertIn('exact', settings.VERBOSE_LOOKUPS)
def test_disable_help_text(self):
self.assertFalse(settings.DISABLE_HELP_TEXT)
def test_strictness(self):
self.assertEqual(settings.STRICTNESS, STRICTNESS.RETURN_NO_RESULTS)
def test_help_text_filter(self):
self.assertTrue(settings.HELP_TEXT_FILTER)
def test_help_text_exclude(self):
self.assertTrue(settings.HELP_TEXT_EXCLUDE)
def test_empty_choice_label(self):
self.assertEqual(settings.EMPTY_CHOICE_LABEL, '---------')
def test_null_choice_label(self):
self.assertIsNone(settings.NULL_CHOICE_LABEL)
def test_null_choice_value(self):
self.assertEqual(settings.NULL_CHOICE_VALUE, 'null')
class StrictnessTests(TestCase):
class F(FilterSet):
class Meta:
model = User
fields = []
def test_settings_default(self):
self.assertEqual(self.F().strict, STRICTNESS.RETURN_NO_RESULTS)
def test_ignore(self):
with override_settings(FILTERS_STRICTNESS=STRICTNESS.IGNORE):
self.assertEqual(self.F().strict, STRICTNESS.IGNORE)
def test_return_no_results(self):
with override_settings(FILTERS_STRICTNESS=STRICTNESS.RETURN_NO_RESULTS):
self.assertEqual(self.F().strict, STRICTNESS.RETURN_NO_RESULTS)
def test_raise_validation_error(self):
with override_settings(FILTERS_STRICTNESS=STRICTNESS.RAISE_VALIDATION_ERROR):
self.assertEqual(self.F().strict, STRICTNESS.RAISE_VALIDATION_ERROR)
def test_legacy_ignore(self):
with override_settings(FILTERS_STRICTNESS=False):
self.assertEqual(self.F().strict, STRICTNESS.IGNORE)
def test_legacy_return_no_results(self):
with override_settings(FILTERS_STRICTNESS=True):
self.assertEqual(self.F().strict, STRICTNESS.RETURN_NO_RESULTS)
def test_legacy_raise_validation_error(self):
with override_settings(FILTERS_STRICTNESS='RAISE'):
self.assertEqual(self.F().strict, STRICTNESS.RAISE_VALIDATION_ERROR)
def test_legacy_differentiation(self):
self.assertNotEqual(STRICTNESS.IGNORE, False)
self.assertNotEqual(STRICTNESS.RETURN_NO_RESULTS, True)
self.assertNotEqual(STRICTNESS.RAISE_VALIDATION_ERROR, 'RAISE')
class OverrideSettingsTests(TestCase):

View File

@ -1,718 +0,0 @@
import functools
import warnings
import mock
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from django_filters import FilterSet
from django_filters.conf import Settings
from django_filters.filters import Filter, CharFilter, MethodFilter
from django_filters.filterset import STRICTNESS
from .models import User
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
fields = '__all__'
class FilterSetContainerDeprecationTests(TestCase):
def test__iter__notification(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
[obj for obj in UserFilter()]
self.assertEqual(len(w), 1)
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
def test__getitem__notification(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
with self.assertRaises(IndexError):
UserFilter()[0]
self.assertEqual(len(w), 1)
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
def test__len__notification(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
len(UserFilter())
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
def test__count__notification(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
UserFilter().count()
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
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:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = User
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message))
def test_fields_is_none(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = User
fields = None
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Setting 'Meta.fields = None' is undocumented behavior", str(w[-1].message))
def test_fields_not_set_ignore_unknown(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = NetworkSetting
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message))
self.assertNotIn('mask', F.base_filters.keys())
def test_fields_not_set_with_override(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
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))

View File

@ -20,24 +20,21 @@ from django_filters.filters import ChoiceFilter
from django_filters.filters import DateRangeFilter
from django_filters.filters import DateFromToRangeFilter
from django_filters.filters import DateTimeFromToRangeFilter
# from django_filters.filters import DateTimeFilter
from django_filters.filters import DurationFilter
from django_filters.filters import MultipleChoiceFilter
from django_filters.filters import ModelChoiceFilter
from django_filters.filters import TypedMultipleChoiceFilter
from django_filters.filters import ModelMultipleChoiceFilter
from django_filters.filters import NumberFilter
from django_filters.filters import OrderingFilter
from django_filters.filters import RangeFilter
from django_filters.filters import TimeRangeFilter
# from django_filters.widgets import LinkWidget
from django_filters.exceptions import FieldLookupError
from .models import User
from .models import Comment
from .models import Book
# from .models import Restaurant
from .models import Article
# from .models import NetworkSetting
# from .models import SubnetMaskField
from .models import Company
from .models import Location
from .models import Account
@ -129,12 +126,20 @@ class BooleanFilterTests(TestCase):
class ChoiceFilterTests(TestCase):
def test_filtering(self):
@classmethod
def setUpTestData(cls):
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)
Article.objects.create(author_id=1, published=now())
Article.objects.create(author_id=2, published=now())
Article.objects.create(author_id=3, published=now())
Article.objects.create(author_id=4, published=now())
Article.objects.create(author_id=None, published=now())
def test_filtering(self):
class F(FilterSet):
class Meta:
model = User
@ -160,11 +165,6 @@ class ChoiceFilterTests(TestCase):
If you explicitly declare ChoiceFilter fields you **MUST** pass `choices`.
"""
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)
class F(FilterSet):
status = ChoiceFilter(choices=STATUS_CHOICES)
@ -186,6 +186,38 @@ class ChoiceFilterTests(TestCase):
f = F({'status': '0'})
self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username, False)
def test_filtering_on_empty_choice(self):
class F(FilterSet):
class Meta:
model = User
fields = ['status']
f = F({'status': ''})
self.assertQuerysetEqual(f.qs,
['aaron', 'alex', 'jacob', 'carl'],
lambda o: o.username, False)
def test_filtering_on_null_choice(self):
choices = [(u.pk, str(u)) for u in User.objects.order_by('id')]
class F(FilterSet):
author = ChoiceFilter(
choices=choices,
null_value='null',
null_label='NULL',
)
class Meta:
model = Article
fields = ['author']
# sanity check to make sure the filter is setup correctly
f = F({'author': '1'})
self.assertQuerysetEqual(f.qs, ['alex'], lambda o: str(o.author), False)
f = F({'author': 'null'})
self.assertQuerysetEqual(f.qs, [None], lambda o: o.author, False)
class MultipleChoiceFilterTests(TestCase):
@ -221,6 +253,41 @@ class MultipleChoiceFilterTests(TestCase):
f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
class TypedMultipleChoiceFilterTests(TestCase):
def test_filtering(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)
class F(FilterSet):
status = TypedMultipleChoiceFilter(choices=STATUS_CHOICES, coerce=lambda x: x[0:2])
class Meta:
model = User
fields = ['status']
qs = User.objects.all().order_by('username')
f = F(queryset=qs)
self.assertQuerysetEqual(
f.qs, ['aa', 'ja', 'al', 'ca'],
lambda o: o.username[0:2], False)
f = F({'status': ['0']}, queryset=qs)
self.assertQuerysetEqual(
f.qs, ['ca'], lambda o: o.username[0:2])
f = F({'status': ['0', '1']}, queryset=qs)
self.assertQuerysetEqual(
f.qs, ['al', 'ca'], lambda o: o.username[0:2])
f = F({'status': ['0', '1', '2']}, queryset=qs)
self.assertQuerysetEqual(
f.qs, ['aa', 'al', 'ca', 'ja'], lambda o: o.username[0:2])
class DateFilterTests(TestCase):
def test_filtering(self):
@ -418,6 +485,34 @@ class ModelChoiceFilterTests(TestCase):
f = F({'author': jacob.pk}, queryset=qs)
self.assertQuerysetEqual(f.qs, [1, 3], lambda o: o.pk, False)
def test_callable_queryset(self):
# Sanity check for callable queryset arguments.
# Ensure that nothing is improperly cached
User.objects.create(username='alex')
jacob = User.objects.create(username='jacob')
aaron = User.objects.create(username='aaron')
def users(request):
return User.objects.filter(pk__lt=request.user.pk)
class F(FilterSet):
author = ModelChoiceFilter(name='author', queryset=users)
class Meta:
model = Comment
fields = ['author']
qs = Comment.objects.all()
request = mock.Mock()
request.user = jacob
f = F(queryset=qs, request=request).filters['author'].field
self.assertQuerysetEqual(f.queryset, [1], lambda o: o.pk, False)
request.user = aaron
f = F(queryset=qs, request=request).filters['author'].field
self.assertQuerysetEqual(f.queryset, [1, 2], lambda o: o.pk, False)
class ModelMultipleChoiceFilterTests(TestCase):
@ -1644,7 +1739,7 @@ class MiscFilterSetTests(TestCase):
model = User
fields = ['account']
qs = mock.MagicMock()
qs = mock.NonCallableMagicMock()
f = F({'account': 'jdoe'}, queryset=qs)
result = f.qs
self.assertNotEqual(qs, result)
@ -1665,6 +1760,8 @@ class MiscFilterSetTests(TestCase):
self.assertQuerysetEqual(f.qs, [], lambda o: o.pk)
def test_filter_with_initial(self):
# Initial values are a form presentation option - the FilterSet should
# not use an initial value as a default value to filter by.
class F(FilterSet):
status = ChoiceFilter(choices=STATUS_CHOICES, initial=1)
@ -1673,8 +1770,10 @@ class MiscFilterSetTests(TestCase):
fields = ['status']
qs = User.objects.all()
users = ['alex', 'jacob', 'aaron', 'carl']
f = F(queryset=qs)
self.assertQuerysetEqual(f.qs, ['alex'], lambda o: o.username)
self.assertQuerysetEqual(f.qs.order_by('pk'), users, lambda o: o.username)
f = F({'status': 0}, queryset=qs)
self.assertQuerysetEqual(f.qs, ['carl'], lambda o: o.username)

View File

@ -1,13 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals
from collections import OrderedDict
from datetime import date, time, timedelta, datetime
import inspect
import mock
import warnings
from django import forms
from django.test import TestCase, override_settings
from django.utils import translation
from django.utils.translation import ugettext as _
from django_filters import filters, widgets
from django_filters.fields import (
@ -24,6 +27,7 @@ from django_filters.filters import (
BooleanFilter,
ChoiceFilter,
MultipleChoiceFilter,
TypedMultipleChoiceFilter,
DateFilter,
DateTimeFilter,
TimeFilter,
@ -48,6 +52,28 @@ from django_filters.filters import (
from tests.models import Book, User
class ModuleImportTests(TestCase):
def is_filter(self, name, value):
return (
isinstance(value, type) and issubclass(value, Filter)
)
def test_imports(self):
# msg = "Expected `filters.%s` to be imported in `filters.__all__`"
filter_classes = [
key for key, value
in inspect.getmembers(filters)
if isinstance(value, type) and issubclass(value, Filter)
]
# sanity check
self.assertIn('Filter', filter_classes)
self.assertIn('BooleanFilter', filter_classes)
for f in filter_classes:
self.assertIn(f, filters.__all__)
class FilterTests(TestCase):
def test_creation(self):
@ -64,27 +90,6 @@ class FilterTests(TestCase):
f = Filter()
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, 'Filter')
def test_field_with_exclusion(self):
f = Filter(exclude=True)
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, 'This is an exclusion filter')
@override_settings(FILTERS_HELP_TEXT_FILTER=False)
def test_default_field_settings(self):
f = Filter()
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, '')
@override_settings(FILTERS_HELP_TEXT_EXCLUDE=False)
def test_field_with_exclusion_settings(self):
f = Filter(exclude=True)
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, '')
def test_field_with_single_lookup_expr(self):
f = Filter(lookup_expr='iexact')
@ -102,7 +107,6 @@ class FilterTests(TestCase):
f = Filter(lookup_expr=None, exclude=True)
field = f.field
self.assertIsInstance(field, LookupTypeField)
self.assertEqual(field.help_text, 'This is an exclusion filter')
def test_field_with_list_lookup_expr(self):
f = Filter(lookup_expr=('istartswith', 'iendswith'))
@ -119,8 +123,7 @@ class FilterTests(TestCase):
f.field
mocked.assert_called_once_with(required=False,
label='somelabel',
widget='somewidget',
help_text=mock.ANY)
widget='somewidget')
def test_field_extra_params(self):
with mock.patch.object(Filter, 'field_class',
@ -128,8 +131,8 @@ class FilterTests(TestCase):
f = Filter(someattr='someattr')
f.field
mocked.assert_called_once_with(required=mock.ANY,
label=mock.ANY, widget=mock.ANY,
help_text=mock.ANY,
label=mock.ANY,
widget=mock.ANY,
someattr='someattr')
def test_field_with_required_filter(self):
@ -137,8 +140,9 @@ class FilterTests(TestCase):
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)
def test_filtering(self):
qs = mock.Mock(spec=['filter'])
@ -212,16 +216,6 @@ class FilterTests(TestCase):
result = qs.distinct.assert_called_once_with()
self.assertNotEqual(qs, result)
def test_lookup_type_deprecation(self):
"""
Make sure user is alerted when using deprecated ``lookup_type``.
"""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
Filter(lookup_type='exact')
self.assertEqual(len(w), 1)
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
class CustomFilterWithBooleanCheckTests(TestCase):
@ -316,6 +310,82 @@ class ChoiceFilterTests(TestCase):
field = f.field
self.assertIsInstance(field, forms.ChoiceField)
def test_empty_choice(self):
# default value
f = ChoiceFilter(choices=[('a', 'a')])
self.assertEqual(f.field.choices, [
('', '---------'),
('a', 'a'),
])
# set value, allow blank label
f = ChoiceFilter(choices=[('a', 'a')], empty_label='')
self.assertEqual(f.field.choices, [
('', ''),
('a', 'a'),
])
# disable empty choice w/ None
f = ChoiceFilter(choices=[('a', 'a')], empty_label=None)
self.assertEqual(f.field.choices, [
('a', 'a'),
])
def test_null_choice(self):
# default is to be disabled
f = ChoiceFilter(choices=[('a', 'a')], )
self.assertEqual(f.field.choices, [
('', '---------'),
('a', 'a'),
])
# set label, allow blank label
f = ChoiceFilter(choices=[('a', 'a')], null_label='')
self.assertEqual(f.field.choices, [
('', '---------'),
('null', ''),
('a', 'a'),
])
# set null value
f = ChoiceFilter(choices=[('a', 'a')], null_value='NULL', null_label='')
self.assertEqual(f.field.choices, [
('', '---------'),
('NULL', ''),
('a', 'a'),
])
# explicitly disable
f = ChoiceFilter(choices=[('a', 'a')], null_label=None)
self.assertEqual(f.field.choices, [
('', '---------'),
('a', 'a'),
])
@override_settings(
FILTERS_EMPTY_CHOICE_LABEL='EMPTY LABEL',
FILTERS_NULL_CHOICE_LABEL='NULL LABEL',
FILTERS_NULL_CHOICE_VALUE='NULL VALUE', )
def test_settings_overrides(self):
f = ChoiceFilter(choices=[('a', 'a')], )
self.assertEqual(f.field.choices, [
('', 'EMPTY LABEL'),
('NULL VALUE', 'NULL LABEL'),
('a', 'a'),
])
def test_callable_choices(self):
def choices():
yield ('a', 'a')
yield ('b', 'b')
f = ChoiceFilter(choices=choices)
self.assertEqual(f.field.choices, [
('', '---------'),
('a', 'a'),
('b', 'b'),
])
class MultipleChoiceFilterTests(TestCase):
@ -454,6 +524,143 @@ class MultipleChoiceFilterTests(TestCase):
expected_pks, item[1], item[0]))
class TypedMultipleChoiceFilterTests(TestCase):
def test_default_field(self):
f = TypedMultipleChoiceFilter()
field = f.field
self.assertIsInstance(field, forms.TypedMultipleChoiceField)
def test_filtering_requires_name(self):
qs = mock.Mock(spec=['filter'])
f = TypedMultipleChoiceFilter()
with self.assertRaises(TypeError):
f.filter(qs, ['value'])
def test_conjoined_default_value(self):
f = TypedMultipleChoiceFilter()
self.assertFalse(f.conjoined)
def test_conjoined_true(self):
f = TypedMultipleChoiceFilter(conjoined=True)
self.assertTrue(f.conjoined)
def test_filtering(self):
qs = mock.Mock(spec=['filter'])
f = TypedMultipleChoiceFilter(name='somefield')
with mock.patch('django_filters.filters.Q') as mockQclass:
mockQ1, mockQ2 = mock.MagicMock(), mock.MagicMock()
mockQclass.side_effect = [mockQ1, mockQ2]
f.filter(qs, ['value'])
self.assertEqual(mockQclass.call_args_list,
[mock.call(), mock.call(somefield='value')])
mockQ1.__ior__.assert_called_once_with(mockQ2)
qs.filter.assert_called_once_with(mockQ1.__ior__.return_value)
qs.filter.return_value.distinct.assert_called_once_with()
def test_filtering_exclude(self):
qs = mock.Mock(spec=['exclude'])
f = TypedMultipleChoiceFilter(name='somefield', exclude=True)
with mock.patch('django_filters.filters.Q') as mockQclass:
mockQ1, mockQ2 = mock.MagicMock(), mock.MagicMock()
mockQclass.side_effect = [mockQ1, mockQ2]
f.filter(qs, ['value'])
self.assertEqual(mockQclass.call_args_list,
[mock.call(), mock.call(somefield='value')])
mockQ1.__ior__.assert_called_once_with(mockQ2)
qs.exclude.assert_called_once_with(mockQ1.__ior__.return_value)
qs.exclude.return_value.distinct.assert_called_once_with()
def test_filtering_on_required_skipped_when_len_of_value_is_len_of_field_choices(self):
qs = mock.Mock(spec=[])
f = TypedMultipleChoiceFilter(name='somefield', required=True)
f.always_filter = False
result = f.filter(qs, [])
self.assertEqual(len(f.field.choices), 0)
self.assertEqual(qs, result)
f.field.choices = ['some', 'values', 'here']
result = f.filter(qs, ['some', 'values', 'here'])
self.assertEqual(qs, result)
result = f.filter(qs, ['other', 'values', 'there'])
self.assertEqual(qs, result)
def test_filtering_skipped_with_empty_list_value_and_some_choices(self):
qs = mock.Mock(spec=[])
f = TypedMultipleChoiceFilter(name='somefield')
f.field.choices = ['some', 'values', 'here']
result = f.filter(qs, [])
self.assertEqual(qs, result)
def test_filter_conjoined_true(self):
"""Tests that a filter with `conjoined=True` returns objects that
have all the values included in `value`. For example filter
users that have all of this books.
"""
book_kwargs = {'price': 1, 'average_rating': 1}
books = []
books.append(Book.objects.create(**book_kwargs))
books.append(Book.objects.create(**book_kwargs))
books.append(Book.objects.create(**book_kwargs))
books.append(Book.objects.create(**book_kwargs))
books.append(Book.objects.create(**book_kwargs))
books.append(Book.objects.create(**book_kwargs))
user1 = User.objects.create()
user2 = User.objects.create()
user3 = User.objects.create()
user4 = User.objects.create()
user5 = User.objects.create()
user1.favorite_books.add(books[0], books[1])
user2.favorite_books.add(books[0], books[1], books[2])
user3.favorite_books.add(books[1], books[2])
user4.favorite_books.add(books[2], books[3])
user5.favorite_books.add(books[4], books[5])
filter_list = (
((books[0].pk, books[0].pk), # values
[1, 2]), # list of user.pk that have `value` books
((books[1].pk, books[1].pk),
[1, 2, 3]),
((books[2].pk, books[2].pk),
[2, 3, 4]),
((books[3].pk, books[3].pk),
[4, ]),
((books[4].pk, books[4].pk),
[5, ]),
((books[0].pk, books[1].pk),
[1, 2]),
((books[0].pk, books[2].pk),
[2, ]),
((books[1].pk, books[2].pk),
[2, 3]),
((books[2].pk, books[3].pk),
[4, ]),
((books[4].pk, books[5].pk),
[5, ]),
((books[3].pk, books[4].pk),
[]),
)
users = User.objects.all()
for item in filter_list:
f = TypedMultipleChoiceFilter(name='favorite_books__pk', conjoined=True)
queryset = f.filter(users, item[0])
expected_pks = [c[0] for c in queryset.values_list('pk')]
self.assertListEqual(
expected_pks,
item[1],
'Lists Differ: {0} != {1} for case {2}'.format(
expected_pks, item[1], item[0]))
class DateFilterTests(TestCase):
def test_default_field(self):
@ -500,6 +707,33 @@ class ModelChoiceFilterTests(TestCase):
self.assertIsInstance(field, forms.ModelChoiceField)
self.assertEqual(field.queryset, qs)
def test_callable_queryset(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])
qs_callable = mock.Mock(return_value=qs)
f = ModelChoiceFilter(queryset=qs_callable)
f.parent = mock.Mock(request=request)
field = f.field
qs_callable.assert_called_with(request)
self.assertEqual(field.queryset, qs)
def test_get_queryset_override(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])
class F(ModelChoiceFilter):
get_queryset = mock.create_autospec(ModelChoiceFilter.get_queryset, return_value=qs)
f = F()
f.parent = mock.Mock(request=request)
field = f.field
f.get_queryset.assert_called_with(f, request)
self.assertEqual(field.queryset, qs)
class ModelMultipleChoiceFilterTests(TestCase):
@ -530,6 +764,19 @@ class ModelMultipleChoiceFilterTests(TestCase):
self.assertEqual(list(f.filter(qs, ['Firstname'])), [user])
self.assertEqual(list(f.filter(qs, [user])), [user])
def test_callable_queryset(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])
qs_callable = mock.Mock(return_value=qs)
f = ModelMultipleChoiceFilter(queryset=qs_callable)
f.parent = mock.Mock(request=request)
field = f.field
qs_callable.assert_called_with(request)
self.assertEqual(field.queryset, qs)
class NumberFilterTests(TestCase):
@ -1036,6 +1283,7 @@ class OrderingFilterTests(TestCase):
)
self.assertSequenceEqual(f.field.choices, (
('', '---------'),
('a', 'A'),
('b', 'B'),
))
@ -1046,6 +1294,7 @@ class OrderingFilterTests(TestCase):
)
self.assertSequenceEqual(f.field.choices, (
('', '---------'),
('c', 'C'),
('-c', 'C (descending)'),
('d', 'D'),
@ -1059,12 +1308,28 @@ class OrderingFilterTests(TestCase):
)
self.assertSequenceEqual(f.field.choices, (
('', '---------'),
('c', 'foo'),
('-c', 'foo (descending)'),
('d', 'D'),
('-d', 'D (descending)'),
))
def test_field_labels_descending(self):
f = OrderingFilter(
fields=['username'],
field_labels={
'username': 'BLABLA',
'-username': 'XYZXYZ',
}
)
self.assertEqual(f.field.choices, [
('', '---------'),
('username', 'BLABLA'),
('-username', 'XYZXYZ'),
])
def test_normalize_fields(self):
f = OrderingFilter.normalize_fields
O = OrderedDict
@ -1104,3 +1369,31 @@ class OrderingFilterTests(TestCase):
self.assertIsInstance(widget, widgets.BaseCSVWidget)
self.assertIsInstance(widget, forms.Select)
def test_translation_sanity(self):
with translation.override('pl'):
self.assertEqual(_('Username'), 'Nazwa użytkownika')
self.assertEqual(_('%s (descending)') % _('Username'), 'Nazwa użytkownika (malejąco)')
def test_translation_default_label(self):
with translation.override('pl'):
f = OrderingFilter(fields=['username'])
self.assertEqual(f.field.choices, [
('', '---------'),
('username', 'Nazwa użytkownika'),
('-username', 'Nazwa użytkownika (malejąco)'),
])
def test_translation_override_label(self):
with translation.override('pl'):
f = OrderingFilter(
fields=['username'],
field_labels={'username': 'BLABLA'},
)
self.assertEqual(f.field.choices, [
('', '---------'),
('username', 'BLABLA'),
('-username', 'BLABLA (malejąco)'),
])

View File

@ -5,8 +5,9 @@ import unittest
import django
from django.db import models
from django.test import TestCase
from django.test import TestCase, override_settings
from django_filters.constants import STRICTNESS
from django_filters.filterset import FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
from django_filters.filters import Filter
@ -320,6 +321,39 @@ class FilterSetClassCreationTests(TestCase):
self.assertListEqual(list(F.base_filters),
['title', 'price', 'average_rating'])
def test_model_no_fields_or_exclude(self):
with self.assertRaises(AssertionError) as excinfo:
class F(FilterSet):
class Meta:
model = Book
self.assertIn(
"Setting 'Meta.model' without either 'Meta.fields' or 'Meta.exclude'",
str(excinfo.exception)
)
def test_model_fields_empty(self):
class F(FilterSet):
class Meta:
model = Book
fields = []
self.assertEqual(len(F.declared_filters), 0)
self.assertEqual(len(F.base_filters), 0)
self.assertListEqual(list(F.base_filters), [])
def test_model_exclude_empty(self):
# equivalent to fields = '__all__'
class F(FilterSet):
class Meta:
model = Book
exclude = []
self.assertEqual(len(F.declared_filters), 0)
self.assertEqual(len(F.base_filters), 3)
self.assertListEqual(list(F.base_filters),
['title', 'price', 'average_rating'])
def test_declared_and_model_derived(self):
class F(FilterSet):
username = CharFilter()
@ -394,9 +428,12 @@ class FilterSetClassCreationTests(TestCase):
class Meta:
model = Book
fields = ('username', 'price', 'other', 'another')
self.assertEqual(excinfo.exception.args, (
"Meta.fields contains a field that isn't defined "
"on this FilterSet: other",))
self.assertEqual(
str(excinfo.exception),
"'Meta.fields' contains fields that are not defined on this FilterSet: "
"other, another"
)
def test_meta_fields_dictionary_containing_unknown(self):
with self.assertRaises(TypeError):
@ -495,6 +532,16 @@ class FilterSetClassCreationTests(TestCase):
self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask'])
def test_custom_declared_field_no_warning(self):
class F(FilterSet):
mask = CharFilter()
class Meta:
model = NetworkSetting
fields = ['mask']
self.assertEqual(list(F.base_filters.keys()), ['mask'])
def test_filterset_for_proxy_model(self):
class F(FilterSet):
class Meta:
@ -562,6 +609,59 @@ class FilterSetInstantiationTests(TestCase):
f = F(queryset=m)
self.assertEqual(f.queryset, m)
def test_creating_with_request(self):
class F(FilterSet):
class Meta:
model = User
fields = ['username']
m = mock.Mock()
f = F(request=m)
self.assertEqual(f.request, m)
class FilterSetStrictnessTests(TestCase):
def test_settings_default(self):
class F(FilterSet):
class Meta:
model = User
fields = []
# Ensure default is not IGNORE
self.assertEqual(F().strict, STRICTNESS.RETURN_NO_RESULTS)
# override and test
with override_settings(FILTERS_STRICTNESS=STRICTNESS.IGNORE):
self.assertEqual(F().strict, STRICTNESS.IGNORE)
def test_meta_value(self):
class F(FilterSet):
class Meta:
model = User
fields = []
strict = STRICTNESS.IGNORE
self.assertEqual(F().strict, STRICTNESS.IGNORE)
def test_init_default(self):
class F(FilterSet):
class Meta:
model = User
fields = []
strict = STRICTNESS.IGNORE
strict = STRICTNESS.RAISE_VALIDATION_ERROR
self.assertEqual(F(strict=strict).strict, strict)
def test_legacy_value(self):
class F(FilterSet):
class Meta:
model = User
fields = []
self.assertEqual(F(strict=False).strict, STRICTNESS.IGNORE)
class FilterSetTogetherTests(TestCase):
@ -642,6 +742,20 @@ class FilterMethodTests(TestCase):
self.assertEqual(f.filters['f'].filter.method, filter_f)
self.assertIsInstance(f.filters['f'].filter, FilterMethod)
def test_request_available_during_method_called(self):
class F(FilterSet):
f = Filter(method='filter_f')
def filter_f(self, qs, name, value):
# call mock request object to prove self.request can be accessed
self.request()
m = mock.Mock()
f = F({}, queryset=User.objects.all(), request=m)
# call the filter
f.filters['f'].filter.method(User.objects.all(), 'f', '')
m.assert_called_once_with()
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
@ -664,6 +778,24 @@ class FilterMethodTests(TestCase):
self.assertIn('parent', str(w.exception))
self.assertIn('filter_f', str(w.exception))
def test_method_self_is_parent(self):
# Ensure the method isn't 're-parented' on the `FilterMethod` helper class.
# Filter methods should have access to the filterset's properties.
request = mock.Mock()
class F(FilterSet):
f = CharFilter(method='filter_f')
class Meta:
model = User
fields = []
def filter_f(inner_self, qs, name, value):
self.assertIsInstance(inner_self, F)
self.assertIs(inner_self.request, request)
F({'f': 'foo'}, request=request, queryset=User.objects.all()).qs
def test_method_unresolvable(self):
class F(FilterSet):
f = Filter(method='filter_f')
@ -704,7 +836,6 @@ class FilterMethodTests(TestCase):
self.assertIs(f.filter, TestFilter.filter)
@unittest.skip('TODO: remove when relevant deprecations have been completed')
class MiscFilterSetTests(TestCase):
def test_no__getitem__(self):

View File

@ -2,7 +2,7 @@ from __future__ import absolute_import
from __future__ import unicode_literals
from django import forms
from django.test import TestCase
from django.test import TestCase, override_settings
from django_filters.filterset import FilterSet
from django_filters.filters import CharFilter
@ -66,8 +66,10 @@ class FilterSetFormTests(TestCase):
f = F().form
self.assertEqual(len(f.fields), 1)
self.assertIn('status', f.fields)
self.assertEqual(sorted(f.fields['status'].choices),
sorted(STATUS_CHOICES))
self.assertSequenceEqual(
f.fields['status'].choices,
(('', '---------'), ) + STATUS_CHOICES
)
def test_form_fields_exclusion(self):
class F(FilterSet):
@ -78,12 +80,36 @@ class FilterSetFormTests(TestCase):
fields = ('title',)
f = F().form
self.assertEqual(f.fields['title'].help_text, "This is an exclusion filter")
self.assertEqual(f.fields['title'].label, "Exclude title")
def test_complex_form_fields(self):
class F(FilterSet):
username = CharFilter(label='Filter for users with username')
exclude_username = CharFilter(name='username', lookup_expr='iexact', exclude=True)
class Meta:
model = User
fields = {
'status': ['exact', 'lt', 'gt'],
'favorite_books__title': ['iexact', 'in'],
'manager_of__users__username': ['exact'],
}
fields = F().form.fields
self.assertEqual(fields['username'].label, 'Filter for users with username')
self.assertEqual(fields['exclude_username'].label, 'Exclude username')
self.assertEqual(fields['status'].label, 'Status')
self.assertEqual(fields['status__lt'].label, 'Status is less than')
self.assertEqual(fields['status__gt'].label, 'Status is greater than')
self.assertEqual(fields['favorite_books__title__iexact'].label, 'Favorite books title')
self.assertEqual(fields['favorite_books__title__in'].label, 'Favorite books title is in')
self.assertEqual(fields['manager_of__users__username'].label, 'Manager of users username')
def test_form_fields_using_widget(self):
class F(FilterSet):
status = ChoiceFilter(widget=forms.RadioSelect,
choices=STATUS_CHOICES)
choices=STATUS_CHOICES,
empty_label=None)
class Meta:
model = User
@ -93,8 +119,10 @@ class FilterSetFormTests(TestCase):
self.assertEqual(len(f.fields), 2)
self.assertIn('status', f.fields)
self.assertIn('username', f.fields)
self.assertEqual(sorted(f.fields['status'].choices),
sorted(STATUS_CHOICES))
self.assertSequenceEqual(
f.fields['status'].choices,
STATUS_CHOICES
)
self.assertIsInstance(f.fields['status'].widget, forms.RadioSelect)
def test_form_field_with_custom_label(self):
@ -118,8 +146,8 @@ class FilterSetFormTests(TestCase):
fields = ('book_title',)
f = F().form
self.assertEqual(f.fields['book_title'].label, None)
self.assertEqual(f['book_title'].label, 'Book title')
self.assertEqual(f.fields['book_title'].label, "Title")
self.assertEqual(f['book_title'].label, "Title")
def test_form_field_with_manual_name_and_label(self):
class F(FilterSet):
@ -180,3 +208,24 @@ class FilterSetFormTests(TestCase):
self.assertEqual(
list(f.fields['manager'].choices), [('', '---------'), (3, 'manager')]
)
def test_disabled_help_text(self):
class F(FilterSet):
class Meta:
model = Book
fields = {
# 'in' lookups are CSV-based, which have a `help_text`.
'title': ['in']
}
self.assertEqual(
F().form.fields['title__in'].help_text,
'Multiple values may be separated by commas.'
)
with override_settings(FILTERS_DISABLE_HELP_TEXT=True):
self.assertEqual(
F().form.fields['title__in'].help_text,
''
)

View File

@ -2,11 +2,16 @@
import unittest
import django
from django.test import TestCase
from django.test import TestCase, override_settings
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields.related import ForeignObjectRel
from django.utils.functional import Promise
from django_filters.utils import get_model_field, resolve_field
from django_filters.utils import (
get_field_parts, get_model_field, resolve_field,
verbose_field_name, verbose_lookup_expr, label_for_filter
)
from django_filters.exceptions import FieldLookupError
from .models import User
@ -16,6 +21,34 @@ from .models import HiredWorker
from .models import Business
class GetFieldPartsTests(TestCase):
def test_field(self):
parts = get_field_parts(User, 'username')
self.assertEqual(len(parts), 1)
self.assertIsInstance(parts[0], models.CharField)
def test_non_existent_field(self):
result = get_model_field(User, 'unknown__name')
self.assertIsNone(result)
def test_forwards_related_field(self):
parts = get_field_parts(User, 'favorite_books__title')
self.assertEqual(len(parts), 2)
self.assertIsInstance(parts[0], models.ManyToManyField)
self.assertIsInstance(parts[1], models.CharField)
def test_reverse_related_field(self):
parts = get_field_parts(User, 'manager_of__users__username')
self.assertEqual(len(parts), 3)
self.assertIsInstance(parts[0], ForeignObjectRel)
self.assertIsInstance(parts[1], models.ManyToManyField)
self.assertIsInstance(parts[2], models.CharField)
class GetModelFieldTests(TestCase):
def test_non_existent_field(self):
@ -172,3 +205,83 @@ class ResolveFieldTests(TestCase):
exc = str(context.exception)
self.assertIn(str(model_field), exc)
self.assertIn('date__invalid_lookup', exc)
class VerboseFieldNameTests(TestCase):
def test_none(self):
verbose_name = verbose_field_name(Article, None)
self.assertEqual(verbose_name, '[invalid name]')
def test_invalid_name(self):
verbose_name = verbose_field_name(Article, 'foobar')
self.assertEqual(verbose_name, '[invalid name]')
def test_field(self):
verbose_name = verbose_field_name(Article, 'author')
self.assertEqual(verbose_name, 'author')
def test_field_with_verbose_name(self):
verbose_name = verbose_field_name(Article, 'name')
self.assertEqual(verbose_name, 'title')
def test_forwards_related_field(self):
verbose_name = verbose_field_name(Article, 'author__username')
self.assertEqual(verbose_name, 'author username')
def test_backwards_related_field(self):
verbose_name = verbose_field_name(Book, 'lovers__first_name')
self.assertEqual(verbose_name, 'lovers first name')
def test_lazy_text(self):
# sanity check
field = User._meta.get_field('username')
self.assertIsInstance(field.verbose_name, Promise)
verbose_name = verbose_field_name(User, 'username')
self.assertEqual(verbose_name, 'username')
class VerboseLookupExprTests(TestCase):
def test_exact(self):
# Exact should default to empty. A verbose expression is unnecessary,
# and this behavior works well with list syntax for `Meta.fields`.
verbose_lookup = verbose_lookup_expr('exact')
self.assertEqual(verbose_lookup, '')
def test_verbose_expression(self):
verbose_lookup = verbose_lookup_expr('date__lt')
self.assertEqual(verbose_lookup, 'date is less than')
def test_missing_keys(self):
verbose_lookup = verbose_lookup_expr('foo__bar__lt')
self.assertEqual(verbose_lookup, 'foo bar is less than')
@override_settings(FILTERS_VERBOSE_LOOKUPS={'exact': 'is equal to'})
def test_overridden_settings(self):
verbose_lookup = verbose_lookup_expr('exact')
self.assertEqual(verbose_lookup, 'is equal to')
class LabelForFilterTests(TestCase):
def test_standard_label(self):
label = label_for_filter(Article, 'name', 'in')
self.assertEqual(label, 'Title is in')
def test_related_model(self):
label = label_for_filter(Article, 'author__first_name', 'in')
self.assertEqual(label, 'Author first name is in')
def test_exclusion_label(self):
label = label_for_filter(Article, 'name', 'in', exclude=True)
self.assertEqual(label, 'Exclude title is in')
def test_related_model_exclusion(self):
label = label_for_filter(Article, 'author__first_name', 'in', exclude=True)
self.assertEqual(label, 'Exclude author first name is in')
def test_exact_lookup(self):
label = label_for_filter(Article, 'name', 'exact')
self.assertEqual(label, 'Title')

View File

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.forms import TextInput, Select
from django_filters.widgets import BooleanWidget
from django_filters.widgets import BooleanWidget, QueryArrayWidget
from django_filters.widgets import BaseCSVWidget
from django_filters.widgets import CSVWidget
from django_filters.widgets import RangeWidget
@ -279,3 +279,74 @@ class CSVSelectTests(TestCase):
self.assertHTMLEqual(w.render('price', [1, 2]), """
<input type="text" name="price" value="1,2" />""")
class QueryArrayWidgetTests(TestCase):
def test_widget_value_from_datadict(self):
w = QueryArrayWidget()
# Values can be provided as csv string: ?foo=bar,baz
data = {'price': None}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
data = {'price': '1'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1'])
data = {'price': '1,2'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(sorted(result), ['1', '2'])
data = {'price': '1,,2'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(sorted(result), ['1', '2'])
data = {'price': '1,'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1'])
data = {'price': ','}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
data = {'price': ''}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
result = w.value_from_datadict({}, {}, 'price')
self.assertEqual(result, [])
# Values can be provided as query array: ?foo[]=bar&foo[]=baz
data = {'price[]': None}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
data = {'price[]': ['1']}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1'])
data = {'price[]': ['1', '2']}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(sorted(result), ['1', '2'])
data = {'price[]': ['1', '', '2']}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(sorted(result), ['1', '2'])
data = {'price[]': ['1', '']}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1'])
data = {'price[]': ['', '']}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
data = {'price[]': []}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [])
result = w.value_from_datadict({}, {}, 'price')
self.assertEqual(result, [])

38
tox.ini
View File

@ -1,26 +1,38 @@
[tox]
envlist =
{py27,py33,py34,py35}-django18-restframework{33,34},
{py27,py34,py35}-django{19,110}-restframework34,
{py27,py34,py35}-django-latest-restframework34
{py27,py33,py34,py35}-django18-restframework{34,35},
{py27,py34,py35}-django{19,110}-restframework{34,35},
{py27,py34,py35}-djangolatest-restframeworklatest,
warnings
[testenv]
basepython =
py27: python2.7
py33: python3.3
py34: python3.4
py35: python3.5
commands = ./runtests.py {posargs}
commands = coverage run --source django_filters ./runtests.py {posargs}
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
django18: django>=1.8.0,<1.9.0
django19: django>=1.9.0,<1.10.0
django110: django>=1.10.0,<1.11.0
django-latest: https://github.com/django/django/archive/master.tar.gz
restframework33: djangorestframework>=3.3,<3.4
djangolatest: https://github.com/django/django/archive/master.tar.gz
restframework34: djangorestframework>=3.4,<3.5
restframework35: djangorestframework>=3.5,<3.6
restframeworklatest: https://github.com/tomchristie/django-rest-framework/archive/master.tar.gz
-rrequirements/test-ci.txt
[testenv:py27-djangolatest-restframeworklatest]
ignore_outcome = True
[testenv:py34-djangolatest-restframeworklatest]
ignore_outcome = True
[testenv:py35-djangolatest-restframeworklatest]
ignore_outcome = True
[testenv:warnings]
ignore_outcome = True
commands = python -Werror ./runtests.py {posargs}
deps =
https://github.com/django/django/archive/master.tar.gz
https://github.com/tomchristie/django-rest-framework/archive/master.tar.gz
-rrequirements/test-ci.txt