Merge pull request #458 from carltongibson/develop
Version 0.14 Release
This commit is contained in:
commit
0b90d877ad
|
@ -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]+))?
|
||||
|
|
11
.travis.yml
11
.travis.yml
|
@ -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
|
||||
|
||||
|
|
17
CHANGES.rst
17
CHANGES.rst
|
@ -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)
|
||||
---------------------------
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
)
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
16
docs/conf.py
16
docs/conf.py
|
@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.'
|
|||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.13.0'
|
||||
version = '0.14.0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.13.0'
|
||||
release = '0.14.0'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
@ -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()]
|
||||
|
|
|
@ -19,4 +19,5 @@ Contents:
|
|||
ref/fields
|
||||
ref/widgets
|
||||
ref/settings
|
||||
migration
|
||||
tests
|
||||
|
|
|
@ -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']
|
|
@ -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
|
||||
|
|
|
@ -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``
|
||||
|
|
|
@ -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'}))
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
-r maintainer.txt
|
||||
coverage==3.7.1
|
||||
django-discover-runner
|
||||
mock
|
||||
|
|
|
@ -1,3 +1 @@
|
|||
django-discover-runner
|
||||
mock
|
||||
coverage==3.7.1
|
||||
-r test.txt
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
3
setup.py
3
setup.py
|
@ -6,7 +6,7 @@ f = open('README.rst')
|
|||
readme = f.read()
|
||||
f.close()
|
||||
|
||||
version = '0.13.0'
|
||||
version = '0.14.0'
|
||||
|
||||
if sys.argv[-1] == 'publish':
|
||||
if os.system("pip freeze | grep wheel"):
|
||||
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'])
|
|
@ -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)
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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__'))
|
||||
|
|
|
@ -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')]
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
7
tox.ini
7
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue