Merge pull request #458 from carltongibson/develop

Version 0.14 Release
This commit is contained in:
Carlton Gibson 2016-08-14 21:15:11 +02:00 committed by GitHub
commit 0b90d877ad
32 changed files with 950 additions and 232 deletions

View File

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

View File

@ -2,15 +2,17 @@ language: python
sudo: false
cache: pip
python:
- "2.7"
- "3.2"
- "3.3"
- "3.4"
- "3.5"
env:
- DJANGO='https://github.com/django/django/archive/master.tar.gz'
- DJANGO='django>=1.10.0,<1.11.0'
- DJANGO='django>=1.9.0,<1.10.0'
- DJANGO='django>=1.8.0,<1.9.0'
@ -27,15 +29,12 @@ notifications:
matrix:
exclude:
- python: "3.2"
env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
- python: "3.2"
env: DJANGO='django>=1.9.0,<1.10.0'
- python: "3.3"
env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
- python: "3.3"
env: DJANGO='django>=1.10.0,<1.11.0'
- python: "3.3"
env: DJANGO='django>=1.9.0,<1.10.0'
allow_failures:
- env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
fast_finish: true

View File

@ -1,3 +1,20 @@
Version 0.14.0 (2016-08-14)
---------------------------
* Add support for filtering on DurationField (new in Django 1.8).
* Fix UUIDFilter import issue
* Improve FieldLookupError message
* Add filters_for_model to improve extensibility
* Fix limit_choices_to behavior with callables
* Fix distinct behavior for range filters
* Various Minor Clean up issues.
Version 0.13.0 (2016-03-11)
---------------------------

View File

@ -12,8 +12,8 @@ Full documentation on `read the docs`_.
Requirements
------------
* Python 2.7, 3.2, 3.3, 3.4, 3.5
* Django 1.8, 1.9
* Python 2.7, 3.3, 3.4, 3.5
* Django 1.8, 1.9, 1.10
Installation
------------
@ -69,5 +69,5 @@ Support
If you have questions about usage or development you can join the
`mailing list`_.
.. _`read the docs`: https://django-filter.readthedocs.org/en/latest/
.. _`read the docs`: https://django-filter.readthedocs.io/en/latest/
.. _`mailing list`: http://groups.google.com/group/django-filter

View File

@ -3,7 +3,7 @@ from __future__ import absolute_import
from .filterset import FilterSet
from .filters import *
__version__ = '0.13.0'
__version__ = '0.14.0'
def parse_version(version):

View File

@ -15,3 +15,10 @@ def remote_model(field):
if django.VERSION >= (1, 9):
return remote_field(field).model
return remote_field(field).to
def remote_queryset(field):
model = remote_model(field)
limit_choices_to = field.get_limit_choices_to()
return model._default_manager.complex_filter(limit_choices_to)

View File

@ -0,0 +1,9 @@
from django.core.exceptions import FieldError
class FieldLookupError(FieldError):
def __init__(self, model_field, lookup_expr):
super(FieldLookupError, self).__init__(
"Unsupported lookup '%s' for field '%s'." % (lookup_expr, model_field)
)

View File

@ -20,12 +20,29 @@ from .fields import (
__all__ = [
'Filter', 'CharFilter', 'BooleanFilter', 'ChoiceFilter',
'TypedChoiceFilter', 'MultipleChoiceFilter', 'DateFilter',
'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter',
'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter',
'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter',
'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter'
'AllValuesFilter',
'BooleanFilter',
'CharFilter',
'ChoiceFilter',
'DateFilter',
'DateFromToRangeFilter',
'DateRangeFilter',
'DateTimeFilter',
'DateTimeFromToRangeFilter',
'DurationFilter',
'Filter',
'IsoDateTimeFilter',
'MethodFilter',
'ModelChoiceFilter',
'ModelMultipleChoiceFilter',
'MultipleChoiceFilter',
'NumberFilter',
'NumericRangeFilter',
'RangeFilter',
'TimeFilter',
'TimeRangeFilter',
'TypedChoiceFilter',
'UUIDFilter',
]
@ -247,6 +264,10 @@ class TimeFilter(Filter):
field_class = forms.TimeField
class DurationFilter(Filter):
field_class = forms.DurationField
class ModelChoiceFilter(Filter):
field_class = forms.ModelChoiceField
@ -272,6 +293,8 @@ class NumericRangeFilter(Filter):
qs = self.get_method(qs)(**{'%s__startswith' % self.name: value.start})
if value.stop is not None:
qs = self.get_method(qs)(**{'%s__endswith' % self.name: value.stop})
if self.distinct:
qs = qs.distinct()
return qs
@ -288,16 +311,18 @@ class RangeFilter(Filter):
qs = self.get_method(qs)(**{'%s__gte' % self.name: value.start})
if value.stop is not None:
qs = self.get_method(qs)(**{'%s__lte' % self.name: value.stop})
if self.distinct:
qs = qs.distinct()
return qs
def _truncate(dt):
return dt.replace(hour=0, minute=0, second=0)
return dt.date()
class DateRangeFilter(ChoiceFilter):
options = {
'': (_('Any date'), lambda qs, name: qs.all()),
'': (_('Any date'), lambda qs, name: qs),
1: (_('Today'), lambda qs, name: qs.filter(**{
'%s__year' % name: now().year,
'%s__month' % name: now().month,
@ -331,6 +356,8 @@ class DateRangeFilter(ChoiceFilter):
value = int(value)
except (ValueError, TypeError):
value = ''
assert value in self.options
qs = self.options[value][1](qs, self.name)
if self.distinct:
qs = qs.distinct()
@ -358,6 +385,15 @@ class AllValuesFilter(ChoiceFilter):
return super(AllValuesFilter, self).field
class AllValuesMultipleFilter(MultipleChoiceFilter):
@property
def field(self):
qs = self.model._default_manager.distinct()
qs = qs.order_by(self.name).values_list(self.name, flat=True)
self.extra['choices'] = [(o, o) for o in qs]
return super(AllValuesMultipleFilter, self).field
class BaseCSVFilter(Filter):
"""
Base class for CSV type filters, such as IN and RANGE.

View File

@ -15,11 +15,12 @@ from django.utils import six
from django.utils.text import capfirst
from django.utils.translation import ugettext as _
from .compat import remote_field, remote_model
from .compat import remote_field, remote_model, remote_queryset
from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter,
ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter)
from .utils import try_dbfield, get_model_field, resolve_field
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter,
DurationFilter)
from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate
ORDER_BY_FIELD = 'o'
@ -60,10 +61,15 @@ def get_declared_filters(bases, attrs, with_base_filters=True):
def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
filter_for_reverse_field=None):
field_dict = OrderedDict()
opts = model._meta
if fields is None:
fields = [f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField)]
# 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.
@ -128,6 +134,19 @@ def get_full_clean_override(together):
class FilterSetOptions(object):
def __init__(self, options=None):
if getattr(options, 'model', None) is not None:
if not hasattr(options, 'fields') and not hasattr(options, 'exclude'):
deprecate(
"Not setting Meta.fields with Meta.model is undocumented behavior "
"and may result in unintentionally exposing filter fields. This has "
"been deprecated in favor of setting Meta.fields = '__all__' or by "
"setting the Meta.exclude attribute.", 1)
elif getattr(options, 'fields', -1) is None:
deprecate(
"Setting 'Meta.fields = None' is undocumented behavior and has been "
"deprecated in favor of Meta.fields = '__all__'.", 1)
self.model = getattr(options, 'model', None)
self.fields = getattr(options, 'fields', None)
self.exclude = getattr(options, 'exclude', None)
@ -155,10 +174,11 @@ class FilterSetMetaclass(type):
opts = new_class._meta = FilterSetOptions(
getattr(new_class, 'Meta', None))
# TODO: replace with deprecations
# if opts.model and opts.fields:
if opts.model:
filters = filters_for_model(opts.model, opts.fields, opts.exclude,
new_class.filter_for_field,
new_class.filter_for_reverse_field)
filters = new_class.filters_for_model(opts.model, opts)
filters.update(declared_filters)
else:
filters = declared_filters
@ -195,27 +215,27 @@ FILTER_FOR_DBFIELD_DEFAULTS = {
models.TimeField: {
'filter_class': TimeFilter
},
models.DurationField: {
'filter_class': DurationFilter
},
models.OneToOneField: {
'filter_class': ModelChoiceFilter,
'extra': lambda f: {
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
'queryset': remote_queryset(f),
'to_field_name': remote_field(f).field_name,
}
},
models.ForeignKey: {
'filter_class': ModelChoiceFilter,
'extra': lambda f: {
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
'to_field_name': remote_field(f).field_name
'queryset': remote_queryset(f),
'to_field_name': remote_field(f).field_name,
}
},
models.ManyToManyField: {
'filter_class': ModelMultipleChoiceFilter,
'extra': lambda f: {
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
'queryset': remote_queryset(f),
}
},
models.DecimalField: {
@ -289,15 +309,22 @@ class BaseFilterSet(object):
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'):
@ -353,9 +380,6 @@ class BaseFilterSet(object):
return self._qs
def count(self):
return self.qs.count()
@property
def form(self):
if not hasattr(self, '_form'):
@ -417,6 +441,21 @@ class BaseFilterSet(object):
return [inverted + _filter.name]
return [order_choice]
@classmethod
def filters_for_model(cls, model, opts):
# TODO: remove with deprecations - this emulates the old behavior
fields = opts.fields
if fields is None:
DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS)
DEFAULTS.update(cls.filter_overrides)
fields = get_all_model_fields(model, field_types=DEFAULTS.keys())
return filters_for_model(
model, fields, opts.exclude,
cls.filter_for_field,
cls.filter_for_reverse_field
)
@classmethod
def filter_for_field(cls, f, name, lookup_expr='exact'):
f, lookup_type = resolve_field(f, lookup_expr)
@ -430,8 +469,13 @@ class BaseFilterSet(object):
filter_class, params = cls.filter_for_lookup(f, lookup_type)
default.update(params)
if filter_class is not None:
return filter_class(**default)
assert filter_class is not None, (
"%s resolved field '%s' with '%s' lookup to an unrecognized field "
"type %s. Try adding an override to 'filter_overrides'. See: "
"https://django-filter.readthedocs.io/en/latest/usage.html#overriding-default-filters"
) % (cls.__name__, name, lookup_expr, f.__class__.__name__)
return filter_class(**default)
@classmethod
def filter_for_reverse_field(cls, f, name):
@ -461,6 +505,9 @@ class BaseFilterSet(object):
return None, {}
# perform lookup specific checks
if lookup_type == 'exact' and f.choices:
return ChoiceFilter, {'choices': f.choices}
if lookup_type == 'isnull':
data = try_dbfield(DEFAULTS.get, models.BooleanField)
@ -486,10 +533,6 @@ class BaseFilterSet(object):
return ConcreteRangeFilter, params
# Default behavior
if f.choices:
return ChoiceFilter, {'choices': f.choices}
return filter_class, params
@classmethod
@ -523,7 +566,7 @@ class FilterSet(six.with_metaclass(FilterSetMetaclass, BaseFilterSet)):
def filterset_factory(model):
meta = type(str('Meta'), (object,), {'model': model})
meta = type(str('Meta'), (object,), {'model': model, 'fields': '__all__'})
filterset = type(str('%sFilterSet' % model._meta.object_name),
(FilterSet,), {'Meta': meta})
return filterset

View File

@ -1,12 +1,22 @@
import warnings
from django.conf import settings
from django.core.exceptions import FieldError
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.expressions import Expression
from django.db.models.fields import FieldDoesNotExist
from django.db.models.fields.related import ForeignObjectRel
from django.utils import timezone
from django.utils import six, timezone
from .compat import remote_model
from .compat import remote_field, remote_model
from .exceptions import FieldLookupError
def deprecate(msg, level_modifier=0):
warnings.warn(
"%s See: https://django-filter.readthedocs.io/en/latest/migration.html" % msg,
DeprecationWarning, stacklevel=3 + level_modifier)
def try_dbfield(fn, field_class):
@ -29,6 +39,25 @@ def try_dbfield(fn, field_class):
return data
# TODO: remove field_types arg with deprecations
def get_all_model_fields(model, field_types=None):
opts = model._meta
if field_types is not None:
return [
f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField) and
not (getattr(remote_field(f), 'parent_link', False)) and
f.__class__ in field_types
]
return [
f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField) and
not (getattr(remote_field(f), 'parent_link', False))
]
def get_model_field(model, field_name):
"""
Get a ``model`` field, traversing relationships
@ -78,22 +107,25 @@ def resolve_field(model_field, lookup_expr):
assert len(lookups) > 0
while lookups:
name = lookups[0]
# If there is just one part left, try first get_lookup() so
# that if the lhs supports both transform and lookup for the
# name, then lookup will be picked.
if len(lookups) == 1:
final_lookup = lhs.get_lookup(name)
if not final_lookup:
# We didn't find a lookup. We are going to interpret
# the name as transform, and do an Exact lookup against
# it.
lhs = query.try_transform(lhs, name, lookups)
final_lookup = lhs.get_lookup('exact')
return lhs.output_field, final_lookup.lookup_name
lhs = query.try_transform(lhs, name, lookups)
lookups = lookups[1:]
try:
while lookups:
name = lookups[0]
# If there is just one part left, try first get_lookup() so
# that if the lhs supports both transform and lookup for the
# name, then lookup will be picked.
if len(lookups) == 1:
final_lookup = lhs.get_lookup(name)
if not final_lookup:
# We didn't find a lookup. We are going to interpret
# the name as transform, and do an Exact lookup against
# it.
lhs = query.try_transform(lhs, name, lookups)
final_lookup = lhs.get_lookup('exact')
return lhs.output_field, final_lookup.lookup_name
lhs = query.try_transform(lhs, name, lookups)
lookups = lookups[1:]
except FieldError as e:
six.raise_from(FieldLookupError(model_field, lookup_expr), e)
def handle_timezone(value):

View File

@ -144,6 +144,8 @@ class CSVWidget(forms.TextInput):
value = super(CSVWidget, self).value_from_datadict(data, files, name)
if value is not None:
if value == '': # empty value should parse as an empty list
return []
return value.split(',')
return None

View File

@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.'
# built documents.
#
# The short X.Y version.
version = '0.13.0'
version = '0.14.0'
# The full version, including alpha/beta/rc tags.
release = '0.13.0'
release = '0.14.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -240,3 +240,15 @@ texinfo_documents = [
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# see:
# https://github.com/snide/sphinx_rtd_theme#using-this-theme-locally-then-building-on-read-the-docs
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# only import and set the theme if we're building docs locally
if not on_rtd:
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]

View File

@ -19,4 +19,5 @@ Contents:
ref/fields
ref/widgets
ref/settings
migration
tests

54
docs/migration.txt Normal file
View File

@ -0,0 +1,54 @@
Migrating to 1.0
================
The 1.0 release of django-filter introduces several API changes and refinements
that break forwards compatibility. Below is a list of deprecations and
instructions on how to migrate to the 1.0 release. A forwards-compatible 0.15
release has also been created to help with migration. It is compatible with
both the existing and new APIs and will raise warnings for deprecated behavior.
QuerySet methods are no longer proxied
--------------------------------------
Details: https://github.com/carltongibson/django-filter/pull/440
The ``__iter__()``, ``__len__()``, ``__getitem__()``, ``count()`` methods are
no longer proxied from the queryset. To fix this, call the methods on the
``.qs`` property itself.
.. code-block:: python
f = UserFilter(request.GET, queryset=User.objects.all())
# 0.x
for obj in f:
...
# 1.0
for obj in f.qs:
...
Filters no longer autogenerated when Meta.fields is not specified
-----------------------------------------------------------------
Details: https://github.com/carltongibson/django-filter/pull/450
FilterSets had an undocumented behavior of autogenerating filters for all
model fields when either ``Meta.fields`` was not specified or when set to
``None``. This can lead to potentially unsafe data or schema exposure and
has been deprecated in favor of explicitly setting ``Meta.fields`` to the
``'__all__'`` special value. You may also blacklist fields by setting
the ``Meta.exclude`` attribute.
.. code-block:: python
class UserFilter(FilterSet):
class Meta:
model = User
fields = '__all__'
# or
class UserFilter(FilterSet):
class Meta:
model = User
exclude = ['password']

View File

@ -40,6 +40,8 @@ additional ones that django-filter provides which may be useful:
* :ref:`CSVWidget <csv-widget>` -- this widget expects a comma separated
value and converts it into a list of string values. It is expected that
the field class handle a list of values as well as type conversion.
* :ref:`RangeWidget <range-widget>` -- this widget is used with ``RangeFilter``
to generate two form input elements using a single field.
``action``
~~~~~~~~~~
@ -245,6 +247,15 @@ Example. ::
fields = ['published']
``DurationFilter``
~~~~~~~~~~~~~~~~~~
Matches on a duration. Used with ``DurationField`` by default.
Supports both Django ('%d %H:%M:%S.%f') and ISO 8601 formatted durations (but
only the sections that are accepted by Python's timedelta, so no year, month,
and week designators, e.g. 'P3DT10H22M').
``ModelChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~
@ -290,7 +301,7 @@ the Postgres Numerical Range Fields, including `IntegerRangeField`, `BigIntegerR
available since Django 1.8. The default widget used is the `RangeField`.
RangeField lookup_exprs can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be
found in the Django docs ([https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields](https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields)).
found in the Django docs (https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields).
If the lower limit value is provided, the filter automatically defaults to `__startswith` as the lookup
type and if only the upper limit value is provided, the filter uses `__endswith`.
@ -449,10 +460,23 @@ database. So if in the DB for the given field you have values of 5, 7, and 9
each of those is present as an option. This is similar to the default behavior
of the admin.
``AllValuesMultipleFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is a ``MultipleChoiceFilter`` whose choices are the current values in the
database. So if in the DB for the given field you have values of 5, 7, and 9
each of those is present as an option. This is similar to the default behavior
of the admin.
.. _base-in-filter:
``BaseInFilter``
~~~~~~~~~~~~~~~~
This is a base class used for generating IN lookup filters. Values are expected to be comma separated.
This is a base class used for creating IN lookup filters. It is expected that
this filter class is used in conjunction with another filter class, as this
class **only** validates that the incoming value is comma-separated. The secondary
filter is then used to validate the individual values.
Example. ::
@ -460,7 +484,7 @@ Example. ::
pass
class F(FilterSet):
id__in = NumberInFilter(name=id, lookup_expr='in')
id__in = NumberInFilter(name='id', lookup_expr='in')
class Meta:
model = User
@ -477,7 +501,9 @@ Example. ::
``BaseRangeFilter``
~~~~~~~~~~~~~~~~~~~
This is a base class used for generating RANGE lookup filters. The base class expects two values two, separated by a comma.
This is a base class used for creating RANGE lookup filters. It behaves
identically to ``BaseInFilter`` with the exception that it expects only two
comma-separated values.
Example. ::
@ -485,7 +511,7 @@ Example. ::
pass
class F(FilterSet):
id__range = NumberRangeFilter(name=id, lookup_expr='range')
id__range = NumberRangeFilter(name='id', lookup_expr='range')
class Meta:
model = User

View File

@ -6,14 +6,89 @@ This document provides a guide on using additional FilterSet features.
Meta options
------------
- model
- fields
- exclude
- :ref:`model <model>`
- :ref:`fields <fields>`
- :ref:`exclude <exclude>`
- :ref:`order_by <order-by>`
- :ref:`form <form>`
- :ref:`together <together>`
.. _model:
Automatic filter generation with ``model``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``FilterSet`` is capable of automatically generating filters for a given
``model``'s fields. Similar to Django's ``ModelForm``, filters are created
based on the underlying model field's type. This option must be combined with
either the ``fields`` or ``exclude`` option, which is the same requirement for
Django's ``ModelForm`` class, detailed `here`__.
__ https://docs.djangoproject.com/en/dev/topics/forms/modelforms/#selecting-the-fields-to-use
.. code-block:: python
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = ['username', 'last_login']
.. _fields:
Declaring filterable ``fields``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``fields`` option is combined with ``model`` to automatically generate
filters. Note that generated filters will not overwrite filters declared on
the ``FilterSet``. The ``fields`` option accepts two syntaxes:
* a list of field names
* a dictionary of field names mapped to a list of lookups
.. code-block:: python
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = ['username', 'last_login']
# or
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = {
'username': ['exact', 'contains'],
'last_login': ['exact', 'year__gt'],
}
The list syntax will create an ``exact`` lookup filter for each field included
in ``fields``. The dictionary syntax will create a filter for each lookup
expression declared for its corresponding model field. These expressions may
include both transforms and lookups, as detailed in the `lookup reference`__.
__ https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups
.. _exclude:
Disable filter fields with ``exclude``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``exclude`` option accepts a blacklist of field names to exclude from
automatic filter generation. Note that this option will not disable filters
declared directly on the ``FilterSet``.
.. code-block:: python
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
exclude = ['password']
.. _order-by:
Ordering using ``order_by``

View File

@ -42,3 +42,17 @@ To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``::
This widget expects a comma separated value and converts it into a list of
string values. It is expected that the field class handle a list of values as
well as type conversion.
.. _range-widget:
``RangeWidget``
~~~~~~~~~~~~~~~
This widget is used with ``RangeFilter`` and its subclasses. It generates two
form input elements which generally act as start/end values in a range.
Under the hood, it is django's ``forms.TextInput`` widget and excepts
the same arguments and values. To use it, pass it to ``widget`` argument of
a ``RangeField``::
date_range = DateFromToRangeFilter(widget=RangeWidget(attrs={'placeholder': 'YYYY/MM/DD'}))

View File

@ -79,7 +79,19 @@ For Django version 1.8, transformed expressions are not supported.
.. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups
While both of these fields are optional, it is recommended that you specify
Common declarative problems
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Below are some of the common problem that occur when declaring filters. It is
recommended that you do read this as it provides a more complete understanding
on how filters work.
Filter ``name`` and ``lookup_expr`` not configured
""""""""""""""""""""""""""""""""""""""""""""""""""
While ``name`` and ``lookup_expr`` are optional, it is recommended that you specify
them. By default, if ``name`` is not specified, the filter's name on the
filterset class will be used. Additionally, ``lookup_expr`` defaults to
``exact``. The following is an example of a misconfigured price filter::
@ -92,14 +104,56 @@ lookup type. Under the hood, this will incorrectly be resolved as::
Produce.objects.filter(price__gt__exact=value)
.. note::
The above will most likely generate a ``FieldError``. The correct configuration
would be::
It's quite common to forget to set the lookup expression for
:code:`CharField` and :code:`TextField` and wonder why a search for "foo"
does not return results for "foobar". This is because the default lookup
type is `exact`, but you probably want to perform an `icontains` lookup.
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt')
For more information on additional arguments, see :ref:`core-arguments`.
Missing ``lookup_expr`` for text search filters
"""""""""""""""""""""""""""""""""""""""""""""""
It's quite common to forget to set the lookup expression for :code:`CharField`
and :code:`TextField` and wonder why a search for "foo" does not return results
for "foobar". This is because the default lookup type is ``exact``, but you
probably want to perform an ``icontains`` lookup.
Filter and lookup expression mismatch (in, range, isnull)
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
It's not always appropriate to directly match a filter to its model field's
type, as some lookups 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
@ -285,13 +339,13 @@ And lastly we need a template::
{{ filter.form.as_p }}
<input type="submit" />
</form>
{% for obj in filter %}
{% for obj in filter.qs %}
{{ obj.name }} - ${{ obj.price }}<br />
{% endfor %}
{% endblock %}
And that's all there is to it! The ``form`` attribute contains a normal
Django form, and when we iterate over the ``FilterSet`` we get the objects in
Django form, and when we iterate over the ``FilterSet.qs`` we get the objects in
the resulting queryset.
@ -304,13 +358,13 @@ You must provide either a ``model`` or ``filterset_class`` argument, similar to
``ListView`` in Django itself::
# urls.py
from django.conf.urls import patterns, url
from django.conf.urls import url
from django_filters.views import FilterView
from myapp.models import Product
urlpatterns = patterns('',
(r'^list/$', FilterView.as_view(model=Product)),
)
urlpatterns = [
url(r'^list/$', FilterView.as_view(model=Product)),
]
You must provide a template at ``<app>/<model>_filter.html`` which gets the
context parameter ``filter``. Additionally, the context will contain
@ -322,12 +376,13 @@ its use is deprecated. It can be found at
to it as the class based view::
# urls.py
from django.conf.urls import patterns, url
from django.conf.urls import url
from django_filters.views import object_filter
from myapp.models import Product
urlpatterns = patterns('',
(r'^list/$', 'django_filters.views.object_filter', {'model': Product}),
)
urlpatterns = [
url(r'^list/$', object_filter, {'model': Product}),
]
The needed template and its context variables will also be the same as the
class-based view above.

View File

@ -4,14 +4,11 @@ Babel==2.2.0
backports.ssl-match-hostname==3.4.0.2
bumpversion==0.5.3
certifi==2015.9.6.2
coverage==3.7.1
django-discover-runner==1.0
docutils==0.12
funcsigs==0.4
Jinja2==2.8
livereload==2.4.0
MarkupSafe==0.23
mock==1.3.0
pathtools==0.1.2
pbr==1.7.0
pkginfo==1.2.1
@ -23,7 +20,6 @@ requests-toolbelt==0.6.0
six==1.9.0
snowballstemmer==1.2.1
Sphinx==1.3.6
sphinx-autobuild==0.5.2
sphinx-rtd-theme==0.1.9
tornado==4.2.1
twine==1.6.5

View File

@ -1,3 +1,4 @@
-r maintainer.txt
coverage==3.7.1
django-discover-runner
mock

View File

@ -1,3 +1 @@
django-discover-runner
mock
coverage==3.7.1
-r test.txt

View File

@ -1,12 +1,17 @@
#!/usr/bin/env python
import os
import sys
import django
from django.core.management import execute_from_command_line
def runshell():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
execute_from_command_line(sys.argv[:1] + ['migrate', '--noinput', '-v', '0'])
execute_from_command_line(
sys.argv[:1] +
['migrate', '--noinput', '-v', '0'] +
(['--run-syncdb'] if django.VERSION >= (1, 9) else []))
argv = sys.argv[:1] + ['shell'] + sys.argv[1:]
execute_from_command_line(argv)

View File

@ -6,7 +6,7 @@ f = open('README.rst')
readme = f.read()
f.close()
version = '0.13.0'
version = '0.14.0'
if sys.argv[-1] == 'publish':
if os.system("pip freeze | grep wheel"):
@ -49,7 +49,6 @@ setup(
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',

View File

@ -6,10 +6,14 @@ from django.db import models
from django.utils.encoding import python_2_unicode_compatible
REGULAR = 0
MANAGER = 1
ADMIN = 2
STATUS_CHOICES = (
(0, 'Regular'),
(1, 'Manager'),
(2, 'Admin'),
(REGULAR, 'Regular'),
(MANAGER, 'Manager'),
(ADMIN, 'Admin'),
)
@ -55,6 +59,20 @@ class User(models.Model):
return self.username
@python_2_unicode_compatible
class ManagerGroup(models.Model):
users = models.ManyToManyField(User,
limit_choices_to={'is_active': True},
related_name='member_of')
manager = models.ForeignKey(User,
limit_choices_to=lambda: {'status': MANAGER},
related_name='manager_of',
on_delete=models.CASCADE)
def __str__(self):
return self.manager.name + ' group'
@python_2_unicode_compatible
class AdminUser(User):
class Meta:
@ -182,3 +200,13 @@ class Business(models.Model):
class UUIDTestModel(models.Model):
uuid = models.UUIDField()
class SpacewalkRecord(models.Model):
"""Cumulative space walk record.
See: https://en.wikipedia.org/wiki/List_of_cumulative_spacewalk_records
"""
astronaut = models.CharField(max_length=100)
duration = models.DurationField()

108
tests/test_deprecations.py Normal file
View File

@ -0,0 +1,108 @@
import warnings
from django.test import TestCase
from django_filters import FilterSet
from django_filters.filters import CharFilter
from .models import User
from .models import NetworkSetting
from .models import SubnetMaskField
class UserFilter(FilterSet):
class Meta:
model = User
fields = '__all__'
class FilterSetContainerDeprecationTests(TestCase):
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 FilterSetMetaDeprecationTests(TestCase):
def test_fields_not_set(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = User
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message))
def test_fields_is_none(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = User
fields = None
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Setting 'Meta.fields = None' is undocumented behavior", str(w[-1].message))
def test_fields_not_set_ignore_unknown(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
class Meta:
model = NetworkSetting
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message))
self.assertNotIn('mask', F.base_filters.keys())
def test_fields_not_set_with_override(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
class F(FilterSet):
filter_overrides = {
SubnetMaskField: {'filter_class': CharFilter}
}
class Meta:
model = NetworkSetting
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertIn("Not setting Meta.fields with Meta.model is undocumented behavior", str(w[-1].message))
self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask'])

View File

@ -13,13 +13,14 @@ from django.utils import timezone
from django_filters.filterset import FilterSet
from django_filters.filters import AllValuesFilter
from django_filters.filters import BaseInFilter
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filters import CharFilter
from django_filters.filters import ChoiceFilter
from django_filters.filters import DateRangeFilter
from django_filters.filters import DateFromToRangeFilter
from django_filters.filters import DateTimeFromToRangeFilter
# from django_filters.filters import DateTimeFilter
from django_filters.filters import DurationFilter
from django_filters.filters import MethodFilter
from django_filters.filters import MultipleChoiceFilter
from django_filters.filters import ModelMultipleChoiceFilter
@ -27,6 +28,7 @@ from django_filters.filters import NumberFilter
from django_filters.filters import RangeFilter
from django_filters.filters import TimeRangeFilter
# from django_filters.widgets import LinkWidget
from django_filters.exceptions import FieldLookupError
from .models import User
from .models import Comment
@ -43,6 +45,7 @@ from .models import Profile
from .models import Node
from .models import DirectedNode
from .models import STATUS_CHOICES
from .models import SpacewalkRecord
class CharFilterTests(TestCase):
@ -300,6 +303,101 @@ class DateTimeFilterTests(TestCase):
self.assertQuerysetEqual(f.qs, [2], lambda o: o.pk)
class DurationFilterTests(TestCase):
"""Duration filter tests.
The preferred format for durations in Django is '%d %H:%M:%S.%f'.
See django.utils.dateparse.parse_duration
Django is not fully ISO 8601 compliant (yet): year, month, and
week designators are not supported, so a duration string
like "P3Y6M4DT12H30M5S" cannot be used.
See https://en.wikipedia.org/wiki/ISO_8601#Durations
"""
def setUp(self):
self.r1 = SpacewalkRecord.objects.create(
astronaut="Anatoly Solovyev",
duration=datetime.timedelta(hours=82, minutes=22))
self.r2 = SpacewalkRecord.objects.create(
astronaut="Michael Lopez-Alegria",
duration=datetime.timedelta(hours=67, minutes=40))
self.r3 = SpacewalkRecord.objects.create(
astronaut="Jerry L. Ross",
duration=datetime.timedelta(hours=58, minutes=32))
self.r4 = SpacewalkRecord.objects.create(
astronaut="John M. Grunsfeld",
duration=datetime.timedelta(hours=58, minutes=30))
self.r5 = SpacewalkRecord.objects.create(
astronaut="Richard Mastracchio",
duration=datetime.timedelta(hours=53, minutes=4))
def test_filtering(self):
class F(FilterSet):
class Meta:
model = SpacewalkRecord
fields = ['duration']
qs = SpacewalkRecord.objects.all()
# Django style: 3 days, 10 hours, 22 minutes.
f = F({'duration': '3 10:22:00'}, queryset=qs)
self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x)
# ISO 8601: 3 days, 10 hours, 22 minutes.
f = F({'duration': 'P3DT10H22M'}, queryset=qs)
self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x)
# Django style: 82 hours, 22 minutes.
f = F({'duration': '82:22:00'}, queryset=qs)
self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x)
# ISO 8601: 82 hours, 22 minutes.
f = F({'duration': 'PT82H22M'}, queryset=qs)
self.assertQuerysetEqual(f.qs, [self.r1], lambda x: x)
def test_filtering_with_single_lookup_expr_dictionary(self):
class F(FilterSet):
class Meta:
model = SpacewalkRecord
fields = {'duration': ['gt', 'gte', 'lt', 'lte']}
qs = SpacewalkRecord.objects.order_by('-duration')
f = F({'duration__gt': 'PT58H30M'}, queryset=qs)
self.assertQuerysetEqual(
f.qs, [self.r1, self.r2, self.r3], lambda x: x)
f = F({'duration__gte': 'PT58H30M'}, queryset=qs)
self.assertQuerysetEqual(
f.qs, [self.r1, self.r2, self.r3, self.r4], lambda x: x)
f = F({'duration__lt': 'PT58H30M'}, queryset=qs)
self.assertQuerysetEqual(
f.qs, [self.r5], lambda x: x)
f = F({'duration__lte': 'PT58H30M'}, queryset=qs)
self.assertQuerysetEqual(
f.qs, [self.r4, self.r5], lambda x: x)
def test_filtering_with_multiple_lookup_exprs(self):
class F(FilterSet):
min_duration = DurationFilter(name='duration', lookup_expr='gte')
max_duration = DurationFilter(name='duration', lookup_expr='lte')
class Meta:
model = SpacewalkRecord
fields = '__all__'
qs = SpacewalkRecord.objects.order_by('duration')
f = F({'min_duration': 'PT55H', 'max_duration': 'PT60H'}, queryset=qs)
self.assertQuerysetEqual(f.qs, [self.r4, self.r3], lambda x: x)
class ModelChoiceFilterTests(TestCase):
def test_filtering(self):
@ -557,8 +655,13 @@ class RangeFilterTests(TestCase):
lambda o: o.title)
@unittest.skip('date-range is funky')
# TODO:
# year & month filtering could be better. The problem is that the test dates
# are relative to today, which is always changing. So, two_weeks_ago is not a
# valid date for 'this month' during the first half of the month, but is during
# the second half. Similary, five_days_ago is not during 'this year' when the
# tests are ran on January 1. All we can test is what is absolutely never valid
# eg, a date from two_years_ago is never a valid date for 'this year'.
class DateRangeFilterTests(TestCase):
def setUp(self):
@ -567,6 +670,7 @@ class DateRangeFilterTests(TestCase):
two_weeks_ago = today - datetime.timedelta(days=14)
two_months_ago = today - datetime.timedelta(days=62)
two_years_ago = today - datetime.timedelta(days=800)
alex = User.objects.create(username='alex')
time = now().time()
Comment.objects.create(date=two_weeks_ago, author=alex, time=time)
@ -584,7 +688,10 @@ class DateRangeFilterTests(TestCase):
fields = ['date']
f = F({'date': '4'}) # this year
self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False)
# assert what is NOT valid for now.
# self.assertQuerysetEqual(f.qs, [1, 3, 4, 5], lambda o: o.pk, False)
self.assertNotIn(2, f.qs.values_list('pk', flat=True))
def test_filtering_for_month(self):
class F(FilterSet):
@ -595,9 +702,12 @@ class DateRangeFilterTests(TestCase):
fields = ['date']
f = F({'date': '3'}) # this month
self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False)
@unittest.expectedFailure
# assert what is NOT valid for now.
# self.assertQuerysetEqual(f.qs, [1, 3, 4], lambda o: o.pk, False)
self.assertNotIn(2, f.qs.values_list('pk', flat=True))
self.assertNotIn(5, f.qs.values_list('pk', flat=True))
def test_filtering_for_week(self):
class F(FilterSet):
date = DateRangeFilter()
@ -735,9 +845,9 @@ class AllValuesFilterTests(TestCase):
fields = ['username']
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'alex'})),
self.assertEqual(list(F({'username': 'alex'}).qs),
[User.objects.get(username='alex')])
self.assertEqual(list(F({'username': 'jose'})),
self.assertEqual(list(F({'username': 'jose'}).qs),
list())
def test_filtering_without_strict(self):
@ -754,12 +864,35 @@ class AllValuesFilterTests(TestCase):
fields = ['username']
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'alex'})),
self.assertEqual(list(F({'username': 'alex'}).qs),
[User.objects.get(username='alex')])
self.assertEqual(list(F({'username': 'jose'})),
self.assertEqual(list(F({'username': 'jose'}).qs),
list(User.objects.all()))
class AllValuesMultipleFilterTests(TestCase):
def test_filtering(self):
User.objects.create(username='alex')
User.objects.create(username='jacob')
User.objects.create(username='aaron')
class F(FilterSet):
username = AllValuesMultipleFilter()
class Meta:
model = User
fields = ['username']
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': ['alex']}).qs),
[User.objects.get(username='alex')])
self.assertEqual(list(F({'username': ['alex', 'jacob']}).qs),
list(User.objects.filter(username__in=['alex', 'jacob'])))
self.assertEqual(list(F({'username': ['jose']}).qs),
list())
class MethodFilterTests(TestCase):
def test_filtering(self):
@ -780,9 +913,9 @@ class MethodFilterTests(TestCase):
)
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'alex'})),
self.assertEqual(list(F({'username': 'alex'}).qs),
[User.objects.get(username='alex')])
self.assertEqual(list(F({'username': 'jose'})),
self.assertEqual(list(F({'username': 'jose'}).qs),
list())
def test_filtering_external(self):
@ -803,9 +936,9 @@ class MethodFilterTests(TestCase):
fields = ['username']
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'alex'})),
self.assertEqual(list(F({'username': 'alex'}).qs),
[User.objects.get(username='alex')])
self.assertEqual(list(F({'username': 'jose'})),
self.assertEqual(list(F({'username': 'jose'}).qs),
list())
@ -827,13 +960,13 @@ class MethodFilterTests(TestCase):
)
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'mike'})),
self.assertEqual(list(F({'username': 'mike'}).qs),
[User.objects.get(username='mike'),
User.objects.get(username='jake')],)
self.assertEqual(list(F({'username': 'jake'})),
self.assertEqual(list(F({'username': 'jake'}).qs),
[User.objects.get(username='mike'),
User.objects.get(username='jake')])
self.assertEqual(list(F({'username': 'aaron'})),
self.assertEqual(list(F({'username': 'aaron'}).qs),
[User.objects.get(username='mike'),
User.objects.get(username='jake')])
@ -852,11 +985,11 @@ class MethodFilterTests(TestCase):
fields = ['username']
self.assertEqual(list(F().qs), list(User.objects.all()))
self.assertEqual(list(F({'username': 'mike'})),
self.assertEqual(list(F({'username': 'mike'}).qs),
list(User.objects.all()))
self.assertEqual(list(F({'username': 'jake'})),
self.assertEqual(list(F({'username': 'jake'}).qs),
list(User.objects.all()))
self.assertEqual(list(F({'username': 'aaron'})),
self.assertEqual(list(F({'username': 'aaron'}).qs),
list(User.objects.all()))
@ -1414,56 +1547,50 @@ class CSVFilterTests(TestCase):
qs = User.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'status__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
self.assertEqual(f.qs.count(), 4)
f = F({'status__in': ','}, queryset=qs)
self.assertEqual(f.qs.count(), 0)
f = F({'status__in': '0'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
self.assertEqual(f.qs.count(), 1)
f = F({'status__in': '0,2'}, queryset=qs)
self.assertEqual(len(f.qs), 3)
self.assertEqual(f.count(), 3)
self.assertEqual(f.qs.count(), 3)
f = F({'status__in': '0,,1'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
f = F({'status__in': '2'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
def test_string_filtering(self):
F = self.user_filter
qs = User.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'username__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
self.assertEqual(f.qs.count(), 4)
f = F({'username__in': ','}, queryset=qs)
self.assertEqual(f.qs.count(), 0)
f = F({'username__in': 'alex'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
self.assertEqual(f.qs.count(), 1)
f = F({'username__in': 'alex,aaron'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
f = F({'username__in': 'alex,,aaron'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
f = F({'username__in': 'alex,'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
self.assertEqual(f.qs.count(), 1)
def test_datetime_filtering(self):
F = self.article_filter
@ -1473,55 +1600,50 @@ class CSVFilterTests(TestCase):
qs = Article.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'published__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
self.assertEqual(f.qs.count(), 4)
f = F({'published__in': ','}, queryset=qs)
self.assertEqual(f.qs.count(), 0)
f = F({'published__in': '%s' % (after, )}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
f = F({'published__in': '%s,%s' % (after, before, )}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'published__in': '%s,,%s' % (after, before, )}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'published__in': '%s,' % (after, )}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
def test_related_filtering(self):
F = self.article_filter
qs = Article.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'author__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
self.assertEqual(f.qs.count(), 4)
f = F({'author__in': ','}, queryset=qs)
self.assertEqual(f.qs.count(), 0)
f = F({'author__in': '1'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
f = F({'author__in': '1,2'}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'author__in': '1,,2'}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'author__in': '1,'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
class MiscFilterSetTests(TestCase):
@ -1597,16 +1719,29 @@ class MiscFilterSetTests(TestCase):
qs = User.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
self.assertEqual(f.qs.count(), 4)
f = F({'status': '0'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
self.assertEqual(f.qs.count(), 1)
f = F({'status': '1'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
self.assertEqual(f.qs.count(), 1)
f = F({'status': '2'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
self.assertEqual(f.qs.count(), 2)
def test_invalid_field_lookup(self):
# We want to ensure that non existent lookups (or just simple misspellings)
# throw a useful exception containg the field and lookup expr.
with self.assertRaises(FieldLookupError) as context:
class F(FilterSet):
class Meta:
model = User
fields = {'username': ['flub']}
exc = str(context.exception)
self.assertIn('tests.User.username', exc)
self.assertIn('flub', exc)

View File

@ -27,6 +27,7 @@ from django_filters.filters import (
DateFilter,
DateTimeFilter,
TimeFilter,
DurationFilter,
ModelChoiceFilter,
ModelMultipleChoiceFilter,
NumberFilter,
@ -282,7 +283,6 @@ class BooleanFilterTests(TestCase):
qs.exclude.assert_called_once_with(somefield__exact=True)
self.assertNotEqual(qs, result)
@unittest.expectedFailure
def test_filtering_skipped_with_blank_value(self):
qs = mock.Mock()
f = BooleanFilter(name='somefield')
@ -379,7 +379,6 @@ class MultipleChoiceFilterTests(TestCase):
result = f.filter(qs, ['other', 'values', 'there'])
self.assertEqual(qs, result)
@unittest.expectedFailure
def test_filtering_skipped_with_empty_list_value_and_some_choices(self):
qs = mock.Mock(spec=[])
f = MultipleChoiceFilter(name='somefield')
@ -475,6 +474,14 @@ class TimeFilterTests(TestCase):
self.assertIsInstance(field, forms.TimeField)
class DurationFilterTests(TestCase):
def test_default_field(self):
f = DurationFilter()
field = f.field
self.assertIsInstance(field, forms.DurationField)
class ModelChoiceFilterTests(TestCase):
def test_default_field_without_queryset(self):
@ -567,14 +574,6 @@ class NumericRangeFilterTests(TestCase):
f.filter(qs, value)
qs.filter.assert_called_once_with(None__overlap=(20, 30))
@unittest.expectedFailure
def test_filtering_lower_field_higher_than_upper_field(self):
qs = mock.Mock(spec=['filter'])
value = mock.Mock(start=35, stop=30)
f = NumericRangeFilter()
result = f.filter(qs, value)
self.assertEqual(qs, result)
def test_zero_to_zero(self):
qs = mock.Mock(spec=['filter'])
value = mock.Mock(start=0, stop=0)
@ -645,25 +644,18 @@ class DateRangeFilterTests(TestCase):
self.assertIsInstance(field, forms.ChoiceField)
def test_filtering(self):
qs = mock.Mock(spec=['all'])
f = DateRangeFilter()
f.filter(qs, '')
qs.all.assert_called_once_with()
# the correct behavior fails right now
@unittest.expectedFailure
def test_filtering_skipped_with_blank_value(self):
# skip filtering, as it's an empty value
qs = mock.Mock(spec=[])
f = DateRangeFilter()
result = f.filter(qs, '')
self.assertEqual(qs, result)
@unittest.expectedFailure
def test_filtering_skipped_with_out_of_range_value(self):
# Field validation should prevent this from occuring
qs = mock.Mock(spec=[])
f = DateRangeFilter()
result = f.filter(qs, 999)
self.assertEqual(qs, result)
with self.assertRaises(AssertionError):
f.filter(qs, 999)
def test_filtering_for_this_year(self):
qs = mock.Mock(spec=['filter'])
@ -685,18 +677,18 @@ class DateRangeFilterTests(TestCase):
def test_filtering_for_7_days(self):
qs = mock.Mock(spec=['filter'])
with mock.patch('django_filters.filters.now'):
with mock.patch('django_filters.filters.timedelta') as mock_td:
with mock.patch(
'django_filters.filters._truncate') as mock_truncate:
mock_dt1, mock_dt2 = mock.MagicMock(), mock.MagicMock()
mock_truncate.side_effect = [mock_dt1, mock_dt2]
f = DateRangeFilter()
f.filter(qs, '2')
self.assertEqual(mock_td.call_args_list,
[mock.call(days=7), mock.call(days=1)])
qs.filter.assert_called_once_with(
None__lt=mock_dt2, None__gte=mock_dt1)
with mock.patch('django_filters.filters.now'), \
mock.patch('django_filters.filters.timedelta') as mock_td, \
mock.patch('django_filters.filters._truncate') as mock_truncate:
mock_d1, mock_d2 = mock.MagicMock(), mock.MagicMock()
mock_truncate.side_effect = [mock_d1, mock_d2]
f = DateRangeFilter()
f.filter(qs, '2')
self.assertEqual(
mock_td.call_args_list,
[mock.call(days=7), mock.call(days=1)]
)
qs.filter.assert_called_once_with(None__lt=mock_d2, None__gte=mock_d1)
def test_filtering_for_today(self):
qs = mock.Mock(spec=['filter'])
@ -802,11 +794,11 @@ class DateTimeFromToRangeFilterTests(TestCase):
result = f.filter(qs, None)
self.assertEqual(qs, result)
def test_filtering_ignores_lookup_type(self):
def test_filtering_ignores_lookup_expr(self):
qs = mock.Mock()
value = mock.Mock(
start=datetime(2015, 4, 7, 8, 30), stop=datetime(2015, 9, 6, 11, 45))
f = DateTimeFromToRangeFilter(lookup_type='gte')
f = DateTimeFromToRangeFilter(lookup_expr='gte')
f.filter(qs, value)
qs.filter.assert_called_once_with(
None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45)))

View File

@ -86,6 +86,7 @@ class DbFieldDefaultFiltersTests(TestCase):
models.SmallIntegerField,
models.TextField,
models.TimeField,
models.DurationField,
models.URLField,
models.ForeignKey,
models.OneToOneField,
@ -152,6 +153,17 @@ class FilterSetFilterForFieldTests(TestCase):
result = FilterSet.filter_for_field(f, 'first_name')
self.assertIsInstance(result, CharFilter)
def test_unknown_field_type_error(self):
f = NetworkSetting._meta.get_field('mask')
with self.assertRaises(AssertionError) as excinfo:
FilterSet.filter_for_field(f, 'mask')
self.assertIn(
"FilterSet resolved field 'mask' with 'exact' lookup "
"to an unrecognized field type SubnetMaskField",
excinfo.exception.args[0])
def test_symmetrical_selfref_m2m_field(self):
f = Node._meta.get_field('adjacents')
result = FilterSet.filter_for_field(f, 'adjacents')
@ -300,6 +312,7 @@ class FilterSetClassCreationTests(TestCase):
class F(FilterSet):
class Meta:
model = Book
fields = '__all__'
self.assertEqual(len(F.declared_filters), 0)
self.assertEqual(len(F.base_filters), 3)
@ -312,6 +325,7 @@ class FilterSetClassCreationTests(TestCase):
class Meta:
model = Book
fields = '__all__'
self.assertEqual(len(F.declared_filters), 1)
self.assertEqual(len(F.base_filters), 4)
@ -420,10 +434,22 @@ class FilterSetClassCreationTests(TestCase):
self.assertListEqual(list(F.base_filters),
['username', 'price'])
def test_meta_exlude_with_no_fields(self):
class F(FilterSet):
class Meta:
model = Book
exclude = ('price', )
self.assertEqual(len(F.declared_filters), 0)
self.assertEqual(len(F.base_filters), 2)
self.assertListEqual(list(F.base_filters),
['title', 'average_rating'])
def test_filterset_class_inheritance(self):
class F(FilterSet):
class Meta:
model = Book
fields = '__all__'
class G(F):
pass
@ -434,6 +460,7 @@ class FilterSetClassCreationTests(TestCase):
class Meta:
model = Book
fields = '__all__'
class G(F):
pass
@ -443,6 +470,7 @@ class FilterSetClassCreationTests(TestCase):
class F(FilterSet):
class Meta:
model = Restaurant
fields = '__all__'
self.assertEqual(set(F.base_filters), set(['name', 'serves_pizza']))
@ -453,13 +481,6 @@ class FilterSetClassCreationTests(TestCase):
self.assertEqual(set(F.base_filters), set(['name', 'serves_pizza']))
def test_custom_field_ignored(self):
class F(FilterSet):
class Meta:
model = NetworkSetting
self.assertEqual(list(F.base_filters.keys()), ['ip'])
def test_custom_field_gets_filter_from_override(self):
class F(FilterSet):
filter_overrides = {
@ -467,6 +488,7 @@ class FilterSetClassCreationTests(TestCase):
class Meta:
model = NetworkSetting
fields = '__all__'
self.assertEqual(list(F.base_filters.keys()), ['ip', 'mask'])
@ -474,22 +496,25 @@ class FilterSetClassCreationTests(TestCase):
class F(FilterSet):
class Meta:
model = User
fields = '__all__'
class ProxyF(FilterSet):
class Meta:
model = AdminUser
fields = '__all__'
self.assertEqual(list(F.base_filters), list(ProxyF.base_filters))
@unittest.expectedFailure
def test_filterset_for_mti_model(self):
class F(FilterSet):
class Meta:
model = Account
fields = '__all__'
class FtiF(FilterSet):
class Meta:
model = BankAccount
fields = '__all__'
# fails due to 'account_ptr' getting picked up
self.assertEqual(
@ -719,7 +744,6 @@ class FilterSetOrderingTests(TestCase):
f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username)
class FilterSetTogetherTests(TestCase):
def setUp(self):
@ -759,3 +783,20 @@ class FilterSetTogetherTests(TestCase):
f = F({'username': 'alex', 'status': 1}, queryset=self.qs)
self.assertEqual(f.qs.count(), 1)
self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk)
@unittest.skip('TODO: remove when relevant deprecations have been completed')
class MiscFilterSetTests(TestCase):
def test_no__getitem__(self):
# The DTL processes variable lookups by the following rules:
# https://docs.djangoproject.com/en/1.9/ref/templates/language/#variables
# A __getitem__ implementation precedes normal attribute access, and in
# the case of #58, will force the queryset to evaluate when it should
# not (eg, when rendering a blank form).
self.assertFalse(hasattr(FilterSet, '__getitem__'))
def test_no_qs_proxying(self):
# The FilterSet should not proxy .qs methods - just access .qs directly
self.assertFalse(hasattr(FilterSet, '__len__'))
self.assertFalse(hasattr(FilterSet, '__iter__'))

View File

@ -8,9 +8,9 @@ from django_filters.filterset import FilterSet
from django_filters.filters import CharFilter
from django_filters.filters import ChoiceFilter
from .models import User
from .models import User, ManagerGroup
from .models import Book
from .models import STATUS_CHOICES
from .models import STATUS_CHOICES, REGULAR, MANAGER
class FilterSetFormTests(TestCase):
@ -39,6 +39,7 @@ class FilterSetFormTests(TestCase):
class F(FilterSet):
class Meta:
model = Book
fields = '__all__'
form = MyForm
f = F().form
@ -229,7 +230,7 @@ class FilterSetFormTests(TestCase):
self.assertNotIn('o', f.fields)
self.assertIn('order', f.fields)
self.assertEqual(f.fields['order'].choices, [('status', 'Status')])
def test_ordering_with_overridden_field_name_and_descending(self):
"""
Set the `order_by_field` on the queryset and ensure that the
@ -272,3 +273,20 @@ class FilterSetFormTests(TestCase):
f = F().form
self.assertEqual(
f.fields['o'].choices, [('status', 'Current status')])
def test_limit_choices_to(self):
User.objects.create(username='inactive', is_active=False, status=REGULAR)
User.objects.create(username='active', is_active=True, status=REGULAR)
User.objects.create(username='manager', is_active=False, status=MANAGER)
class F(FilterSet):
class Meta:
model = ManagerGroup
fields = ['users', 'manager']
f = F().form
self.assertEquals(
list(f.fields['users'].choices), [(2, 'active')]
)
self.assertEquals(
list(f.fields['manager'].choices), [('', '---------'), (3, 'manager')]
)

View File

@ -5,9 +5,9 @@ import django
from django.test import TestCase
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.core.exceptions import FieldError
from django_filters.utils import get_model_field, resolve_field
from django_filters.exceptions import FieldLookupError
from .models import User
from .models import Article
@ -156,11 +156,19 @@ class ResolveFieldTests(TestCase):
def test_invalid_lookup_expression(self):
model_field = Article._meta.get_field('published')
with self.assertRaises(FieldError):
field, lookup = resolve_field(model_field, 'invalid_lookup')
with self.assertRaises(FieldLookupError) as context:
resolve_field(model_field, 'invalid_lookup')
exc = str(context.exception)
self.assertIn(str(model_field), exc)
self.assertIn('invalid_lookup', exc)
def test_invalid_transformed_lookup_expression(self):
model_field = Article._meta.get_field('published')
with self.assertRaises(FieldError):
field, lookup = resolve_field(model_field, 'date__invalid_lookup')
with self.assertRaises(FieldLookupError) as context:
resolve_field(model_field, 'date__invalid_lookup')
exc = str(context.exception)
self.assertIn(str(model_field), exc)
self.assertIn('date__invalid_lookup', exc)

View File

@ -212,9 +212,17 @@ class CSVWidgetTests(TestCase):
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1', '', '2'])
data = {'price': '1,'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1', ''])
data = {'price': ','}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['', ''])
data = {'price': ''}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [''])
self.assertEqual(result, [])
result = w.value_from_datadict({}, {}, 'price')
self.assertEqual(result, None)

View File

@ -1,14 +1,13 @@
[tox]
envlist =
{py27,py32,py33,py34}-django{17,18},
{py27,py34,py35}-django19,
{py27,py33,py34,py35}-django18,
{py27,py34,py35}-django{19,110},
{py27,py34,py35}-django-latest
[testenv]
basepython =
py27: python2.7
py32: python3.2
py33: python3.3
py34: python3.4
py35: python3.5
@ -18,8 +17,8 @@ commands = ./runtests.py
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
django17: django>=1.7.0,<1.8.0
django18: django>=1.8.0,<1.9.0
django19: django>=1.9.0,<1.10.0
django110: django>=1.10.0,<1.11.0
django-latest: https://github.com/django/django/archive/master.tar.gz
-rrequirements/test.txt