Merge pull request #375 from carltongibson/develop

0.13 Release
This commit is contained in:
Carlton Gibson 2016-03-11 20:42:24 +01:00
commit 44af395d9b
32 changed files with 2073 additions and 446 deletions

View File

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

View File

@ -1,6 +1,20 @@
Version 0.13.0 (2016-03-11)
---------------------------
* Add support for filtering by CSV #363
* Add DateTimeFromToRangeFilter #376
* Add Chinese translation #359
* Lots of fixes.
Version 0.12.0 (2016-01-07)
---------------------------
* Raised minimum Django version to 1.8.x
* FEATURE: Add support for custom ORM lookup types #221
* FEATURE: Add JavaScript friendly BooleanWidget #270

View File

@ -8,3 +8,4 @@ recursive-include docs *
recursive-include requirements *
recursive-include tests *
recursive-include django_filters/locale *
prune docs/_build

View File

@ -6,8 +6,8 @@ querysets dynamically.
Full documentation on `read the docs`_.
.. image:: https://secure.travis-ci.org/alex/django-filter.png?branch=master
:target: http://travis-ci.org/alex/django-filter
.. image:: https://travis-ci.org/carltongibson/django-filter.svg?branch=master
:target: https://travis-ci.org/carltongibson/django-filter
Requirements
------------
@ -24,7 +24,7 @@ Install using pip::
Or clone the repo and add to your PYTHONPATH::
git clone git@github.com:alex/django-filter.git
git clone git@github.com:carltongibson/django-filter.git
Usage
-----
@ -46,7 +46,7 @@ And then in your view you could do::
def product_list(request):
filter = ProductFilter(request.GET, queryset=Product.objects.all())
return render_to_response('my_app/template.html', {'filter': filter})
return render(request, 'my_app/template.html', {'filter': filter})
Django-filters additionally supports specifying FilterSet fields using a
dictionary to specify filters with lookup types::

View File

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

17
django_filters/compat.py Normal file
View File

@ -0,0 +1,17 @@
import django
def remote_field(field):
"""
https://docs.djangoproject.com/en/1.9/releases/1.9/#field-rel-changes
"""
if django.VERSION >= (1, 9):
return field.remote_field
return field.rel
def remote_model(field):
if django.VERSION >= (1, 9):
return remote_field(field).model
return remote_field(field).to

View File

@ -5,13 +5,13 @@ from datetime import datetime, time
from collections import namedtuple
from django import forms
from django.conf import settings
from django.utils.dateparse import parse_datetime
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.translation import ugettext_lazy as _
from .widgets import RangeWidget, LookupTypeWidget
from .utils import handle_timezone
from .widgets import RangeWidget, LookupTypeWidget, CSVWidget
class RangeField(forms.MultiValueField):
@ -42,13 +42,24 @@ class DateRangeField(RangeField):
if data_list:
start_date, stop_date = data_list
if start_date:
start_date = datetime.combine(start_date, time.min)
start_date = handle_timezone(
datetime.combine(start_date, time.min))
if stop_date:
stop_date = datetime.combine(stop_date, time.max)
stop_date = handle_timezone(
datetime.combine(stop_date, time.max))
return slice(start_date, stop_date)
return None
class DateTimeRangeField(RangeField):
def __init__(self, *args, **kwargs):
fields = (
forms.DateTimeField(),
forms.DateTimeField())
super(DateTimeRangeField, self).__init__(fields, *args, **kwargs)
class TimeRangeField(RangeField):
def __init__(self, *args, **kwargs):
@ -58,7 +69,12 @@ class TimeRangeField(RangeField):
super(TimeRangeField, self).__init__(fields, *args, **kwargs)
Lookup = namedtuple('Lookup', ('value', 'lookup_type'))
class Lookup(namedtuple('Lookup', ('value', 'lookup_type'))):
# python nature is test __len__ on tuple types for boolean check
def __len__(self):
if not self.value:
return 0
return 2
class LookupTypeField(forms.MultiValueField):
@ -91,7 +107,6 @@ class IsoDateTimeField(forms.DateTimeField):
"""
ISO_8601 = 'iso-8601'
input_formats = [ISO_8601]
default_timezone = timezone.get_default_timezone() if settings.USE_TZ else None
def strptime(self, value, format):
value = force_str(value)
@ -100,12 +115,39 @@ class IsoDateTimeField(forms.DateTimeField):
parsed = parse_datetime(value)
if parsed is None: # Continue with other formats if doesn't match
raise ValueError
# Handle timezone awareness. Copied from:
# https://github.com/tomchristie/django-rest-framework/blob/3.2.0/rest_framework/fields.py#L965-L969
if settings.USE_TZ and not timezone.is_aware(parsed):
return timezone.make_aware(parsed, self.default_timezone)
elif not settings.USE_TZ and timezone.is_aware(parsed):
return timezone.make_naive(parsed, timezone.UTC())
return parsed
return handle_timezone(parsed)
return super(IsoDateTimeField, self).strptime(value, format)
class BaseCSVField(forms.Field):
"""
Base field for validating CSV types. Value validation is performed by
secondary base classes.
ex::
class IntegerCSVField(BaseCSVField, filters.IntegerField):
pass
"""
widget = CSVWidget
def clean(self, value):
if value is None:
return None
return [super(BaseCSVField, self).clean(v) for v in value]
class BaseRangeField(BaseCSVField):
default_error_messages = {
'invalid_values': _('Range query expects two values.')
}
def clean(self, value):
value = super(BaseRangeField, self).clean(value)
if value is not None and len(value) != 2:
raise forms.ValidationError(
self.error_messages['invalid_values'],
code='invalid_values')
return value

View File

@ -1,18 +1,22 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import warnings
from datetime import timedelta
from django import forms
from django.db.models import Q
from django.db.models.sql.constants import QUERY_TERMS
from django.db.models.constants import LOOKUP_SEP
from django.conf import settings
from django.utils import six
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from .fields import (
RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField)
Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
)
__all__ = [
@ -20,25 +24,34 @@ __all__ = [
'TypedChoiceFilter', 'MultipleChoiceFilter', 'DateFilter',
'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter',
'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter',
'DateRangeFilter', 'DateFromToRangeFilter', 'TimeRangeFilter',
'AllValuesFilter', 'MethodFilter'
'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter',
'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter'
]
LOOKUP_TYPES = sorted(QUERY_TERMS)
def _lookup_type_warning():
warnings.warn('lookup_type is deprecated. Use lookup_expr instead.', DeprecationWarning, stacklevel=3)
class Filter(object):
creation_counter = 0
field_class = forms.Field
def __init__(self, name=None, label=None, widget=None, action=None,
lookup_type='exact', required=False, distinct=False, exclude=False, **kwargs):
lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs):
self.name = name
self.label = label
if action:
self.filter = action
self.lookup_type = lookup_type
self.lookup_expr = lookup_expr
if 'lookup_type' in kwargs:
_lookup_type_warning()
self.lookup_expr = kwargs.pop('lookup_type')
self.widget = widget
self.required = required
self.extra = kwargs
@ -54,14 +67,32 @@ class Filter(object):
"""
return qs.exclude if self.exclude else qs.filter
def lookup_type():
def fget(self):
_lookup_type_warning()
return self.lookup_expr
def fset(self, value):
_lookup_type_warning()
self.lookup_expr = value
return locals()
lookup_type = property(**lookup_type())
@property
def field(self):
if not hasattr(self, '_field'):
help_text = self.extra.pop('help_text', None)
if help_text is None:
help_text = _('This is an exclusion filter') if self.exclude else _('Filter')
if (self.lookup_type is None or
isinstance(self.lookup_type, (list, tuple))):
if self.exclude and getattr(settings, "FILTERS_HELP_TEXT_EXCLUDE", True):
help_text = _('This is an exclusion filter')
elif not self.exclude and getattr(settings, "FILTERS_HELP_TEXT_FILTER", True):
help_text = _('Filter')
else:
help_text = ''
if (self.lookup_expr is None or
isinstance(self.lookup_expr, (list, tuple))):
lookup = []
@ -71,10 +102,15 @@ class Filter(object):
else:
choice = (x, x)
if self.lookup_type is None:
lookup.append(choice)
elif x in self.lookup_type:
if self.lookup_expr is None:
lookup.append(choice)
else:
if isinstance(x, (list, tuple)) and len(x) == 2:
if x[0] in self.lookup_expr:
lookup.append(choice)
else:
if x in self.lookup_expr:
lookup.append(choice)
self._field = LookupTypeField(self.field_class(
required=self.required, widget=self.widget, **self.extra),
@ -90,7 +126,7 @@ class Filter(object):
lookup = six.text_type(value.lookup_type)
value = value.value
else:
lookup = self.lookup_type
lookup = self.lookup_expr
if value in ([], (), {}, None, ''):
return qs
if self.distinct:
@ -229,7 +265,7 @@ class NumericRangeFilter(Filter):
def filter(self, qs, value):
if value:
if value.start is not None and value.stop is not None:
lookup = '%s__%s' % (self.name, self.lookup_type)
lookup = '%s__%s' % (self.name, self.lookup_expr)
return self.get_method(qs)(**{lookup: (value.start, value.stop)})
else:
if value.start is not None:
@ -305,6 +341,10 @@ class DateFromToRangeFilter(RangeFilter):
field_class = DateRangeField
class DateTimeFromToRangeFilter(RangeFilter):
field_class = DateTimeRangeField
class TimeRangeFilter(RangeFilter):
field_class = TimeRangeField
@ -318,6 +358,65 @@ class AllValuesFilter(ChoiceFilter):
return super(AllValuesFilter, self).field
class BaseCSVFilter(Filter):
"""
Base class for CSV type filters, such as IN and RANGE.
"""
base_field_class = BaseCSVField
def __init__(self, *args, **kwargs):
super(BaseCSVFilter, self).__init__(*args, **kwargs)
class ConcreteCSVField(self.base_field_class, self.field_class):
pass
ConcreteCSVField.__name__ = self._field_class_name(
self.field_class, self.lookup_expr
)
self.field_class = ConcreteCSVField
@classmethod
def _field_class_name(cls, field_class, lookup_expr):
"""
Generate a suitable class name for the concrete field class. This is not
completely reliable, as not all field class names are of the format
<Type>Field.
ex::
BaseCSVFilter._field_class_name(DateTimeField, 'year__in')
returns 'DateTimeYearInField'
"""
# DateTimeField => DateTime
type_name = field_class.__name__
if type_name.endswith('Field'):
type_name = type_name[:-5]
# year__in => YearIn
parts = lookup_expr.split(LOOKUP_SEP)
expression_name = ''.join(p.capitalize() for p in parts)
# DateTimeYearInField
return str('%s%sField' % (type_name, expression_name))
class BaseInFilter(BaseCSVFilter):
def __init__(self, *args, **kwargs):
kwargs.setdefault('lookup_expr', 'in')
super(BaseInFilter, self).__init__(*args, **kwargs)
class BaseRangeFilter(BaseCSVFilter):
base_field_class = BaseRangeField
def __init__(self, *args, **kwargs):
kwargs.setdefault('lookup_expr', 'range')
super(BaseRangeFilter, self).__init__(*args, **kwargs)
class MethodFilter(Filter):
"""
This filter will allow you to run a method that exists on the filterset class

View File

@ -10,15 +10,16 @@ from django.forms.forms import NON_FIELD_ERRORS
from django.core.validators import EMPTY_VALUES
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields import FieldDoesNotExist
from django.db.models.fields.related import ForeignObjectRel
from django.utils import six
from django.utils.text import capfirst
from django.utils.translation import ugettext as _
from .filters import (Filter, CharFilter, BooleanFilter,
from .compat import remote_field, remote_model
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
ORDER_BY_FIELD = 'o'
@ -56,25 +57,6 @@ def get_declared_filters(bases, attrs, with_base_filters=True):
return OrderedDict(filters)
def get_model_field(model, f):
parts = f.split(LOOKUP_SEP)
opts = model._meta
for name in parts[:-1]:
try:
rel = opts.get_field(name)
except FieldDoesNotExist:
return None
if isinstance(rel, ForeignObjectRel):
opts = rel.related_model._meta
else:
opts = rel.rel.to._meta
try:
rel = opts.get_field(parts[-1])
except FieldDoesNotExist:
return None
return rel
def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
filter_for_reverse_field=None):
field_dict = OrderedDict()
@ -99,14 +81,17 @@ def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
# If fields is a dictionary, it must contain lists.
elif isinstance(fields, dict):
# Create a filter for each lookup type.
for lookup_type in fields[f]:
filter_ = filter_for_field(field, f, lookup_type)
for lookup_expr in fields[f]:
filter_ = filter_for_field(field, f, lookup_expr)
if filter_:
filter_name = f
filter_name = LOOKUP_SEP.join([f, lookup_expr])
# Don't add "exact" to filter names
if lookup_type != 'exact':
filter_name = f + LOOKUP_SEP + lookup_type
_exact = LOOKUP_SEP + 'exact'
if filter_name.endswith(_exact):
filter_name = filter_name[:-len(_exact)]
field_dict[filter_name] = filter_
# If fields is a list, it contains strings.
else:
@ -178,9 +163,10 @@ class FilterSetMetaclass(type):
else:
filters = declared_filters
if None in filters.values():
not_defined = next((k for k, v in filters.items() if v is None), False)
if not_defined:
raise TypeError("Meta.fields contains a field that isn't defined "
"on this FilterSet")
"on this FilterSet: {}".format(not_defined))
new_class.declared_filters = declared_filters
new_class.base_filters = filters
@ -212,24 +198,24 @@ FILTER_FOR_DBFIELD_DEFAULTS = {
models.OneToOneField: {
'filter_class': ModelChoiceFilter,
'extra': lambda f: {
'queryset': f.rel.to._default_manager.complex_filter(
f.rel.limit_choices_to),
'to_field_name': f.rel.field_name,
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
'to_field_name': remote_field(f).field_name,
}
},
models.ForeignKey: {
'filter_class': ModelChoiceFilter,
'extra': lambda f: {
'queryset': f.rel.to._default_manager.complex_filter(
f.rel.limit_choices_to),
'to_field_name': f.rel.field_name
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
'to_field_name': remote_field(f).field_name
}
},
models.ManyToManyField: {
'filter_class': ModelMultipleChoiceFilter,
'extra': lambda f: {
'queryset': f.rel.to._default_manager.complex_filter(
f.rel.limit_choices_to),
'queryset': remote_model(f)._default_manager.complex_filter(
remote_field(f).limit_choices_to),
}
},
models.DecimalField: {
@ -355,7 +341,9 @@ class BaseFilterSet(object):
except forms.ValidationError:
pass
if ordered_value in EMPTY_VALUES and self.strict:
# With a None-queryset, ordering must be enforced (#84).
if (ordered_value in EMPTY_VALUES and
self.strict == STRICTNESS.RETURN_NO_RESULTS):
ordered_value = self.form.fields[self.order_by_field].choices[0][0]
if ordered_value:
@ -430,41 +418,24 @@ class BaseFilterSet(object):
return [order_choice]
@classmethod
def filter_for_field(cls, f, name, lookup_type='exact'):
filter_for_field = dict(FILTER_FOR_DBFIELD_DEFAULTS)
filter_for_field.update(cls.filter_overrides)
def filter_for_field(cls, f, name, lookup_expr='exact'):
f, lookup_type = resolve_field(f, lookup_expr)
default = {
'name': name,
'label': capfirst(f.verbose_name),
'lookup_type': lookup_type
'lookup_expr': lookup_expr
}
if f.choices:
default['choices'] = f.choices
return ChoiceFilter(**default)
filter_class, params = cls.filter_for_lookup(f, lookup_type)
default.update(params)
data = filter_for_field.get(f.__class__)
if data is None:
# could be a derived field, inspect parents
for class_ in f.__class__.mro():
# skip if class_ is models.Field or object
# 1st item in mro() is original class
if class_ in (f.__class__, models.Field, object):
continue
data = filter_for_field.get(class_)
if data:
break
if data is None:
return
filter_class = data.get('filter_class')
default.update(data.get('extra', lambda f: {})(f))
if filter_class is not None:
return filter_class(**default)
@classmethod
def filter_for_reverse_field(cls, f, name):
rel = f.field.rel
rel = remote_field(f.field)
queryset = f.field.model._default_manager.all()
default = {
'name': name,
@ -476,6 +447,76 @@ class BaseFilterSet(object):
else:
return ModelChoiceFilter(**default)
@classmethod
def filter_for_lookup(cls, f, lookup_type):
DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS)
DEFAULTS.update(cls.filter_overrides)
data = try_dbfield(DEFAULTS.get, f.__class__) or {}
filter_class = data.get('filter_class')
params = data.get('extra', lambda f: {})(f)
# if there is no filter class, exit early
if not filter_class:
return None, {}
# perform lookup specific checks
if lookup_type == 'isnull':
data = try_dbfield(DEFAULTS.get, models.BooleanField)
filter_class = data.get('filter_class')
params = data.get('extra', lambda f: {})(f)
return filter_class, params
if lookup_type == 'in':
class ConcreteInFilter(BaseInFilter, filter_class):
pass
ConcreteInFilter.__name__ = cls._csv_filter_class_name(
filter_class, lookup_type
)
return ConcreteInFilter, params
if lookup_type == 'range':
class ConcreteRangeFilter(BaseRangeFilter, filter_class):
pass
ConcreteRangeFilter.__name__ = cls._csv_filter_class_name(
filter_class, lookup_type
)
return ConcreteRangeFilter, params
# Default behavior
if f.choices:
return ChoiceFilter, {'choices': f.choices}
return filter_class, params
@classmethod
def _csv_filter_class_name(cls, filter_class, lookup_type):
"""
Generate a suitable class name for a concrete filter class. This is not
completely reliable, as not all filter class names are of the format
<Type>Filter.
ex::
FilterSet._csv_filter_class_name(DateTimeFilter, 'in')
returns 'DateTimeInFilter'
"""
# DateTimeFilter => DateTime
type_name = filter_class.__name__
if type_name.endswith('Filter'):
type_name = type_name[:-6]
# in => In
lookup_name = lookup_type.capitalize()
# DateTimeInFilter
return str('%s%sFilter' % (type_name, lookup_name))
class FilterSet(six.with_metaclass(FilterSetMetaclass, BaseFilterSet)):
pass

View File

@ -0,0 +1,64 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Kane Blueriver <kxxoling@gmail.com>, 2016.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-01-30 17:39+0800\n"
"PO-Revision-Date: 2016-01-30 17:50+0800\n"
"Last-Translator: Kane Blueriver <kxxoling@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: filters.py:62
msgid "This is an exclusion filter"
msgstr "未启用该过滤器"
#: filters.py:62
msgid "Filter"
msgstr "过滤器"
#: filters.py:264
msgid "Any date"
msgstr "任何时刻"
#: filters.py:265
msgid "Today"
msgstr "今日"
#: filters.py:270
msgid "Past 7 days"
msgstr "过去 7 日"
#: filters.py:274
msgid "This month"
msgstr "本月"
#: filters.py:278
msgid "This year"
msgstr "今年"
#: filters.py:281
msgid "Yesterday"
msgstr "昨日"
#: filterset.py:398 filterset.py:409
#, python-format
msgid "%s (descending)"
msgstr "%s降序"
#: filterset.py:411
msgid "Ordering"
msgstr "排序"
#: widgets.py:60
msgid "All"
msgstr "全部"

104
django_filters/utils.py Normal file
View File

@ -0,0 +1,104 @@
from django.conf import settings
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 .compat import remote_model
def try_dbfield(fn, field_class):
"""
Try ``fn`` with the DB ``field_class`` by walking its
MRO until a result is found.
ex::
_try_dbfield(field_dict.get, models.CharField)
"""
# walk the mro, as field_class could be a derived model field.
for cls in field_class.mro():
# skip if cls is models.Field
if cls is models.Field:
continue
data = fn(cls)
if data:
return data
def get_model_field(model, field_name):
"""
Get a ``model`` field, traversing relationships
in the ``field_name``.
ex::
f = get_model_field(Book, 'author__first_name')
"""
parts = field_name.split(LOOKUP_SEP)
opts = model._meta
# walk relationships
for name in parts[:-1]:
try:
rel = opts.get_field(name)
except FieldDoesNotExist:
return None
if isinstance(rel, ForeignObjectRel):
opts = rel.related_model._meta
else:
opts = remote_model(rel)._meta
try:
return opts.get_field(parts[-1])
except FieldDoesNotExist:
return None
def resolve_field(model_field, lookup_expr):
"""
Resolves a ``lookup_expr`` into its final output field, given
the initial ``model_field``. The lookup expression should only contain
transforms and lookups, not intermediary model field parts.
Note:
This method is based on django.db.models.sql.query.Query.build_lookup
For more info on the lookup API:
https://docs.djangoproject.com/en/1.9/ref/models/lookups/
"""
query = model_field.model._default_manager.all().query
lhs = Expression(model_field)
lookups = lookup_expr.split(LOOKUP_SEP)
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:]
def handle_timezone(value):
if settings.USE_TZ and timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_default_timezone())
elif not settings.USE_TZ and timezone.is_aware(value):
return timezone.make_naive(value, timezone.UTC())
return value

View File

@ -1,6 +1,7 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from collections import Iterable
from itertools import chain
try:
from urllib.parse import urlencode
@ -12,6 +13,7 @@ from django.db.models.fields import BLANK_CHOICE_DASH
from django.forms.widgets import flatatt
from django.utils.encoding import force_text
from django.utils.safestring import mark_safe
from django.utils.six import string_types
from django.utils.translation import ugettext as _
@ -96,21 +98,58 @@ class LookupTypeWidget(forms.MultiWidget):
return value
class BooleanWidget(forms.Widget):
class BooleanWidget(forms.Select):
"""Convert true/false values into the internal Python True/False.
This can be used for AJAX queries that pass true/false from JavaScript's
internal types through.
"""
def __init__(self, attrs=None):
choices = (('', _('Unknown')),
('true', _('Yes')),
('false', _('No')))
super(BooleanWidget, self).__init__(attrs, choices)
def render(self, name, value, attrs=None):
try:
value = {
True: 'true',
False: 'false',
'1': 'true',
'0': 'false'
}[value]
except KeyError:
value = ''
return super(BooleanWidget, self).render(name, value, attrs)
def value_from_datadict(self, data, files, name):
"""
"""
value = super(BooleanWidget, self).value_from_datadict(
data, files, name)
value = data.get(name, None)
if isinstance(value, string_types):
value = value.lower()
return {
'1': True,
'0': False,
'true': True,
'false': False,
True: True,
False: False,
}.get(value, None)
class CSVWidget(forms.TextInput):
def _isiterable(self, value):
return isinstance(value, Iterable) and not isinstance(value, string_types)
def value_from_datadict(self, data, files, name):
value = super(CSVWidget, self).value_from_datadict(data, files, name)
if value is not None:
if value.lower() == 'true':
value = True
elif value.lower() == 'false':
value = False
return value.split(',')
return None
return value
def render(self, name, value, attrs=None):
if self._isiterable(value):
value = [force_text(self._format_value(v)) for v in value]
value = ','.join(list(value))
return super(CSVWidget, self).render(name, value, attrs)

View File

@ -48,9 +48,9 @@ copyright = u'2013, Alex Gaynor and others.'
# built documents.
#
# The short X.Y version.
version = '0.12.0'
version = '0.13.0'
# The full version, including alpha/beta/rc tags.
release = '0.12.0'
release = '0.13.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -183,7 +183,7 @@ latex_elements = {
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('asd', 'django-filter.tex', u'django-filter Documentation',
('index', 'django-filter.tex', u'django-filter Documentation',
u'Alex Gaynor and others.', 'manual'),
]
@ -213,7 +213,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('asd', 'django-filter', u'django-filter Documentation',
('index', 'django-filter', u'django-filter Documentation',
[u'Alex Gaynor and others.'], 1)
]
@ -227,7 +227,7 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('asd', 'django-filter', u'django-filter Documentation',
('index', 'django-filter', u'django-filter Documentation',
u'Alex Gaynor and others.', 'django-filter', 'One line description of project.',
'Miscellaneous'),
]

View File

@ -14,7 +14,9 @@ Contents:
install
usage
ref/filterset
ref/filters
ref/fields
ref/widgets
ref/settings
tests

View File

@ -1,5 +1,8 @@
Installing django-filter
------------------------
To install, simply place the ``django_filters`` directory somewhere on your
``PYTHONPATH``, and then add ``'django_filters'`` to your ``INSTALLED_APPS``.
Install with pip:
pip install django-filter
And then add ``'django_filters'`` to your ``INSTALLED_APPS``.

View File

@ -16,5 +16,5 @@ You may set ``input_formats`` to your list of required formats as per the `DateT
f.input_formats = [IsoDateTimeField.ISO_8601] + DateTimeField.input_formats
.. _`DateTimeField Docs`:https://docs.djangoproject.com/en/1.8/ref/forms/fields/#django.forms.DateTimeField.input_formats
.. _`DateTimeField Docs`: https://docs.djangoproject.com/en/1.8/ref/forms/fields/#django.forms.DateTimeField.input_formats

View File

@ -3,6 +3,124 @@ Filter Reference
This is a reference document with a list of the filters and their arguments.
.. _core-arguments:
Core Arguments
--------------
The following are the core arguments that apply to all filters.
``name``
~~~~~~~~
The name of the field this filter is supposed to filter on, if this is not
provided it automatically becomes the filter's name on the ``FilterSet``.
You can traverse "relationship paths" using Django's ``__`` syntax to filter
fields on a related model. eg, ``manufacturer__name``.
``label``
~~~~~~~~~
The label as it will apear in the HTML, analogous to a form field's label
argument.
``widget``
~~~~~~~~~~
The django.form Widget class which will represent the ``Filter``. In addition
to the widgets that are included with Django that you can use there are
additional ones that django-filter provides which may be useful:
* :ref:`LinkWidget <link-widget>` -- this displays the options in a manner
similar to the way the Django Admin does, as a series of links. The link
for the selected option will have ``class="selected"``.
* :ref:`BooleanWidget <boolean-widget>` -- this widget converts its input
into Python's True/False values. It will convert all case variations of
``True`` and ``False`` into the internal Python values.
* :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.
``action``
~~~~~~~~~~
An optional callable that tells the filter how to handle the queryset. It
recieves a ``QuerySet`` and the value to filter on and should return a
``Queryset`` that is filtered appropriately. `action` will default to
``filter_{value-of-name-attribute}``
``lookup_expr``
~~~~~~~~~~~~~~~
The lookup expression that should be performed using `Django's ORM`_.
.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups
A ``list`` or ``tuple`` of lookup types is also accepted, allowing the user to
select the lookup from a dropdown. The list of lookup types are filtered against
``filters.LOOKUP_TYPES``. If `lookup_expr=None` is passed, then a list of all lookup
types will be generated::
class ProductFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr=['exact', 'iexact'])
You can enable custom lookups by adding them to ``LOOKUP_TYPES``::
from django_filters import filters
filters.LOOKUP_TYPES = ['gt', 'gte', 'lt', 'lte', 'custom_lookup_type']
Additionally, you can provide human-friendly help text by overriding ``LOOKUP_TYPES``::
# filters.py
from django_filters import filters
filters.LOOKUP_TYPES = [
('', '---------'),
('exact', 'Is equal to'),
('not_exact', 'Is not equal to'),
('lt', 'Lesser than'),
('gt', 'Greater than'),
('gte', 'Greater than or equal to'),
('lte', 'Lesser than or equal to'),
('startswith', 'Starts with'),
('endswith', 'Ends with'),
('contains', 'Contains'),
('not_contains', 'Does not contain'),
]
``distinct``
~~~~~~~~~~~~
A boolean value that specifies whether the Filter will use distinct on the
queryset. This option can be used to eliminate duplicate results when using filters that span related models. Defaults to ``False``.
``exclude``
~~~~~~~~~~~
A boolean value that specifies whether the Filter should use ``filter`` or ``exclude`` on the queryset.
Defaults to ``False``.
``**kwargs``
~~~~~~~~~~~~
Any additional keyword arguments are stored as the ``extra`` parameter on the filter. They are provided to the accompanying form Field and can be used to provide arguments like ``choices``.
ModelChoiceFilter and ModelMultipleChoiceFilter arguments
---------------------------------------------------------
These arguments apply specifically to ModelChoiceFiler and
ModelMultipleChoiceFilter only.
``queryset``
~~~~~~~~~~~~
``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg.
Filters
-------
@ -171,7 +289,7 @@ than a maximum where only one limit value is provided. This filter is designed t
the Postgres Numerical Range Fields, including `IntegerRangeField`, `BigIntegerRangeField` and `FloatRangeField`,
available since Django 1.8. The default widget used is the `RangeField`.
RangeField lookup_types can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be
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)).
If the lower limit value is provided, the filter automatically defaults to `__startswith` as the lookup
@ -211,14 +329,118 @@ selections for working with date fields.
``DateFromToRangeFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~
Similar to a ``RangeFilter`` except it uses dates instead of numerical values. It can be used with ``DateField`` and ``DateTimeField``.
Similar to a ``RangeFilter`` except it uses dates instead of numerical values. It can be used with ``DateField``. It also works with ``DateTimeField``, but takes into consideration only the date.
Example of using the ``DateField`` field::
class Comment(models.Model):
date = models.DateField()
time = models.TimeField()
class F(FilterSet):
date = DateFromToRangeFilter()
class Meta:
model = Comment
fields = ['date']
# Range: Comments added between 2016-01-01 and 2016-02-01
f = F({'date_0': '2016-01-01', 'date_1': '2016-02-01'})
# Min-Only: Comments added after 2016-01-01
f = F({'date_0': '2016-01-01'})
# Max-Only: Comments added before 2016-02-01
f = F({'date_1': '2016-02-01'})
Example of using the ``DateTimeField`` field::
class Article(models.Model):
published = models.DateTimeField()
class F(FilterSet):
published = DateFromToRangeFilter()
class Meta:
model = Article
fields = ['published']
Article.objects.create(published='2016-01-01 8:00')
Article.objects.create(published='2016-01-20 10:00')
Article.objects.create(published='2016-02-10 12:00')
# Range: Articles published between 2016-01-01 and 2016-02-01
f = F({'published_0': '2016-01-01', 'published_1': '2016-02-01'})
assert len(f.qs) == 2
# Min-Only: Articles published after 2016-01-01
f = F({'published_0': '2016-01-01'})
assert len(f.qs) == 3
# Max-Only: Articles published before 2016-02-01
f = F({'published_1': '2016-02-01'})
assert len(f.qs) == 2
``DateTimeFromToRangeFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Similar to a ``RangeFilter`` except it uses datetime format values instead of numerical values. It can be used with ``DateTimeField``.
Example::
class Article(models.Model):
published = models.DateTimeField()
class F(FilterSet):
published = DateTimeFromToRangeFilter()
class Meta:
model = Article
fields = ['published']
Article.objects.create(published='2016-01-01 8:00')
Article.objects.create(published='2016-01-01 9:30')
Article.objects.create(published='2016-01-02 8:00')
# Range: Articles published 2016-01-01 between 8:00 and 10:00
f = F({'published_0': '2016-01-01 8:00', 'published_1': '2016-01-01 10:00'})
assert len(f.qs) == 2
# Min-Only: Articles published after 2016-01-01 8:00
f = F({'published_0': '2016-01-01 8:00'})
assert len(f.qs) == 3
# Max-Only: Articles published before 2016-01-01 10:00
f = F({'published_1': '2016-01-01 10:00'})
assert len(f.qs) == 2
``TimeRangeFilter``
~~~~~~~~~~~~~~~~~~~
Similar to a ``RangeFilter`` except it uses time format values instead of numerical values. It can be used with ``TimeField``.
Example::
class Comment(models.Model):
date = models.DateField()
time = models.TimeField()
class F(FilterSet):
time = TimeRangeFilter()
class Meta:
model = Comment
fields = ['time']
# Range: Comments added between 8:00 and 10:00
f = F({'time_0': '8:00', 'time_1': '10:00'})
# Min-Only: Comments added after 8:00
f = F({'time_0': '8:00'})
# Max-Only: Comments added before 10:00
f = F({'time_1': '10:00'})
``AllValuesFilter``
~~~~~~~~~~~~~~~~~~~
@ -227,9 +449,60 @@ 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.
``MethodFilter``
``BaseInFilter``
~~~~~~~~~~~~~~~~
This is a base class used for generating IN lookup filters. Values are expected to be comma separated.
Example. ::
class NumberInFilter(BaseInFilter, NumericFilter):
pass
class F(FilterSet):
id__in = NumberInFilter(name=id, lookup_expr='in')
class Meta:
model = User
User.objects.create(username='alex')
User.objects.create(username='jacob')
User.objects.create(username='aaron')
User.objects.create(username='carl')
# In: User with IDs 1 and 3.
f = F({'id__in': '1,3'})
assert len(f.qs) == 2
``BaseRangeFilter``
~~~~~~~~~~~~~~~~~~~
This is a base class used for generating RANGE lookup filters. The base class expects two values two, separated by a comma.
Example. ::
class NumberRangeFilter(BaseInFilter, NumericFilter):
pass
class F(FilterSet):
id__range = NumberRangeFilter(name=id, lookup_expr='range')
class Meta:
model = User
User.objects.create(username='alex')
User.objects.create(username='jacob')
User.objects.create(username='aaron')
User.objects.create(username='carl')
# Range: User with IDs between 1 and 3.
f = F({'id__range': '1,3'})
assert len(f.qs) == 3
``MethodFilter``
~~~~~~~~~~~~~~~~
This is a ``Filter`` that will allow you to run a method that exists on the filter set that
this filter is a property of. Set the `action` to a string that will map to a method on the
filter set class. `action` will default to ``filter_{value-of-name-attribute}``
@ -248,79 +521,3 @@ Example. ::
class Meta:
model = Book
fields = ['published']
Core Arguments
--------------
These arguments apply to the filters described above.
``name``
~~~~~~~~
The name of the field this filter is supposed to filter on, if this is not
provided it automatically becomes the filter's name on the ``FilterSet``.
``label``
~~~~~~~~~
The label as it will apear in the HTML, analogous to a form field's label
argument.
``widget``
~~~~~~~~~~
The django.form Widget class which will represent the ``Filter``. In addition
to the widgets that are included with Django that you can use there are
additional ones that django-filter provides which may be useful:
* ``django_filters.widgets.LinkWidget`` -- this displays the options in a
manner similar to the way the Django Admin does, as a series of links.
The link for the selected option will have ``class="selected"``.
``action``
~~~~~~~~~~
An optional callable that tells the filter how to handle the queryset. It
recieves a ``QuerySet`` and the value to filter on and should return a
``Queryset`` that is filtered appropriately. `action` will default to
``filter_{value-of-name-attribute}``
``lookup_type``
~~~~~~~~~~~~~~~
The type of lookup that should be performed using the [Django ORM](https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups "Django's ORM Lookups").
All the normal options are allowed, and should be provided as a string. You can also
provide either ``None`` or a ``list`` or a ``tuple``. If ``None`` is provided,
then the user can select the lookup type from all the ones available in the Django
ORM. If a ``list`` or ``tuple`` is provided, then the user can select from those
options.
``distinct``
~~~~~~~~~~~~
A boolean value that specifies whether the Filter will use distinct on the
queryset. This option can be used to eliminate duplicate results when using filters that span related models. Defaults to ``False``.
``exclude``
~~~~~~~~~~~
A boolean value that specifies whether the Filter should use ``filter`` or ``exclude`` on the queryset.
Defaults to ``False``.
ModelChoiceFilter and ModelMultipleChoiceFilter arguments
--------------
These arguments apply specifically to ModelChoiceFiler and
ModelMultipleChoiceFilter only.
``queryset``
~~~~~~~~~~~
``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg.
``**kwargs``
~~~~~~~~~~~~
Any extra keyword arguments will be provided to the accompanying form Field.
This can be used to provide arguments like ``choices``.

164
docs/ref/filterset.txt Normal file
View File

@ -0,0 +1,164 @@
FilterSet Guide
===============
This document provides a guide on using additional FilterSet features.
Meta options
------------
- model
- fields
- exclude
- :ref:`order_by <order-by>`
- :ref:`form <form>`
- :ref:`together <together>`
.. _order-by:
Ordering using ``order_by``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can allow the user to control ordering by providing the
``order_by`` argument in the Filter's Meta class. ``order_by`` can be either a
``list`` or ``tuple`` of field names, in which case those are the options, or
it can be a ``bool`` which, if True, indicates that all fields that
the user can filter on can also be sorted on. An example of ordering using a list::
import django_filters
class ProductFilter(django_filters.FilterSet):
price = django_filters.NumberFilter(lookup_expr='lt')
class Meta:
model = Product
fields = ['price', 'release_date']
order_by = ['price']
If you want to control the display of items in ``order_by``, you can set it to
a list or tuple of 2-tuples in the format ``(field_name, display_name)``.
This lets you override the displayed names for your ordering fields::
order_by = (
('name', 'Company Name'),
('average_rating', 'Stars'),
)
Note that the default query parameter name used for ordering is ``o``. You
can override this by setting an ``order_by_field`` attribute on the
``FilterSet`` class to the string value you would like to use.
.. _form:
Custom Forms using ``form``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
The inner ``Meta`` class also takes an optional ``form`` argument. This is a
form class from which ``FilterSet.form`` will subclass. This works similar to
the ``form`` option on a ``ModelAdmin.``
.. _together:
Group fields with ``together``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The inner ``Meta`` class also takes an optional ``together`` argument. This
is a list of lists, each containing field names. For convenience can be a
single list/tuple when dealing with a single set of fields. Fields within a
field set must either be all or none present in the request for
``FilterSet.form`` to be valid::
import django_filters
class ProductFilter(django_filters.FilterSet):
class Meta:
model = Product
fields = ['price', 'release_date', 'rating']
together = ['rating', 'price']
Non-Meta options
----------------
Note that these options do not go in the Meta class, they are specified directly
in your FilterSet class.
- filter_overrides
- order_by_field
- :ref:`strict <strict>`
.. _strict:
``strict``
~~~~~~~~~~
The ``strict`` option controls whether results are returned when an invalid
value is specified by the user for any filter field. By default, ``strict`` is
set to ``STRICTNESS.RETURN_NO_RESULTS`` meaning that an empty queryset is
returned if any field contains an invalid value. You can loosen this behavior
by setting ``strict`` to ``STRICTNESS.IGNORE`` which will effectively ignore a
filter field if its value is invalid. A third option of
``STRICTNESS.RAISE_VALIDATION_ERROR`` will cause a ``ValidationError`` to be
raised if any field contains an invalid value.
Overriding ``FilterSet`` methods
--------------------------------
``filter_for_lookup()``
~~~~~~~~~~~~~~~~~~~~~~~
Prior to version 0.13.0, filter generation did not take into account the
``lookup_expr`` used. This commonly caused malformed filters to be generated
for 'isnull', 'in', and 'range' lookups (as well as transformed lookups). The
current implementation provides the following behavior:
- 'isnull' lookups return a ``BooleanFilter``
- 'in' lookups return a filter derived from the CSV-based ``BaseInFilter``.
- 'range' lookups return a filter derived from the CSV-based ``BaseRangeFilter``.
If you want to override the ``filter_class`` and ``params`` used to instantiate
filters for a model field, you can override ``filter_for_lookup()``. Ex::
class ProductFilter(django_filters.FilterSet):
class Meta:
model = Product
fields = {
'release_date': ['exact', 'range'],
}
@classmethod
def filter_for_lookup(cls, f, lookup_type):
# override date range lookups
if isinstance(f, models.DateField) and lookup_type == 'range':
return django_filters.DateRangeFiler, {}
# use default behavior otherwise
return super(ProductFilter, cls).filter_for_lookup(f, lookup_type)
``get_ordering_field()``
~~~~~~~~~~~~~~~~~~~~~~~~
If you want to use a custom widget, or in any other way override the ordering
field you can override the ``get_ordering_field()`` method on a ``FilterSet``.
This method just needs to return a Form Field.
Ordering on multiple fields, or other complex orderings can be achieved by
overriding the ``FilterSet.get_order_by()`` method. This is passed the selected
``order_by`` value, and is expected to return an iterable of values to pass to
``QuerySet.order_by``. For example, to sort a ``User`` table by last name, then
first name::
class UserFilter(django_filters.FilterSet):
class Meta:
order_by = (
('username', 'Username'),
('last_name', 'Last Name')
)
def get_order_by(self, order_value):
if order_value == 'last_name':
return ['last_name', 'first_name']
return super(UserFilter, self).get_order_by(order_value)

24
docs/ref/settings.txt Normal file
View File

@ -0,0 +1,24 @@
.. _ref-settings:
==================
Available Settings
==================
Here is a list of all available settings of django-filters and their
default values. All settings are prefixed with ``FILTERS_``, although this
is a bit verbose it helps to make it easy to identify these settings.
FILTERS_HELP_TEXT_EXCLUDE
-------------------------
Default: ``True``
Enable / disable the default field ``help_text`` on filters where ``exclude=True`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``.
FILTERS_HELP_TEXT_FILTER
------------------------
Default: ``True``
Enable / disable the default field ``help_text`` on filters where ``exclude=False`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``.

View File

@ -4,13 +4,16 @@ Widget Reference
This is a reference document with a list of the provided widgets and their
arguments.
.. _link-widget:
``LinkWidget``
~~~~~~~~~~~~~~
This widget renders each option as a link, instead of an actual <input>. It has
one method that you can override for additional customizability.
``option_string()`` should return a string with 3 Python keyword argument
placeholders::
placeholders:
1. ``attrs``: This is a string with all the attributes that will be on the
final ``<a>`` tag.
@ -18,6 +21,9 @@ placeholders::
option on the ``<a>`` element.
3. ``label``: This is the text to be displayed to the user.
.. _boolean-widget:
``BooleanWidget``
~~~~~~~~~~~~~~~~~
@ -26,3 +32,13 @@ all case variations of ``True`` and ``False`` into the internal Python values.
To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``::
active = BooleanFilter(widget=BooleanWidget())
.. _csv-widget:
``CSVWidget``
~~~~~~~~~~~~~
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.

View File

@ -28,6 +28,8 @@ price or the release_date. We create a ``FilterSet`` for this::
import django_filters
class ProductFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='iexact')
class Meta:
model = Product
fields = ['price', 'release_date']
@ -35,42 +37,109 @@ price or the release_date. We create a ``FilterSet`` for this::
As you can see this uses a very similar API to Django's ``ModelForm``. Just
like with a ``ModelForm`` we can also override filters, or add new ones using a
declarative syntax::
declarative syntax.
Declaring filters
~~~~~~~~~~~~~~~~~
The declarative syntax provides you with the most flexibility when creating
filters, however it is fairly verbose. We'll use the below example to outline
the :ref:`core filter arguments <core-arguments>` on a ``FilterSet``::
class ProductFilter(django_filters.FilterSet):
price = django_filters.NumberFilter()
price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt')
price__lt = django_filters.NumberFilter(name='price', lookup_expr='lt')
release_year = django_filters.NumberFilter(name='release_date', lookup_expr='year')
release_year__gt = django_filters.NumberFilter(name='release_date', lookup_expr='year__gt')
release_year__lt = django_filters.NumberFilter(name='release_date', lookup_expr='year__lt')
manufacturer__name = django_filters.CharFilter(lookup_expr='icontains')
class Meta:
model = Product
There are two main arguments for filters:
- ``name``: The name of the model field to filter on. You can traverse
"relationship paths" using Django's ``__`` syntax to filter fields on a
related model. ex, ``manufacturer__name``.
- ``lookup_expr``: The `field lookup`_ to use when filtering. Django's ``__``
syntax can again be used in order to support lookup transforms.
ex, ``year__gte``.
.. _`field lookup`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups
Together, the field ``name`` and ``lookup_expr`` represent a complete Django
lookup expression. A detailed explanation of lookup expressions is provided in
Django's `lookup reference`_. django-filter supports expressions containing
both transforms and a final lookup for version 1.9 of Django and above.
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
them. By default, if ``name`` is not specified, the filter's name on the
filterset class will be used. Additionally, ``lookup_expr`` defaults to
``exact``. The following is an example of a misconfigured price filter::
class ProductFilter(django_filters.FilterSet):
price__gt = django_filters.NumberFilter()
The filter instance will have a field name of ``price__gt`` and an ``exact``
lookup type. Under the hood, this will incorrectly be resolved as::
Produce.objects.filter(price__gt__exact=value)
.. note::
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.
For more information on additional arguments, see :ref:`core-arguments`.
Generating filters with Meta.fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The FilterSet Meta class provides a ``fields`` attribute that can be used for
easily specifying multiple filters without significant code duplication. The
base syntax supports a list of multiple field names::
import django_filters
class ProductFilter(django_filters.FilterSet):
price = django_filters.NumberFilter(lookup_type='lt')
class Meta:
model = Product
fields = ['price', 'release_date']
Filters take a ``lookup_type`` argument which specifies what lookup type to
use with `Django's ORM`_. So here when a user entered a price it would show all
Products with a price less than that.
The above generates 'exact' lookups for both the 'price' and 'release_date'
fields.
.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups
**It's quite common to forget to set lookup type for `CharField`s/`TextField`s
and wonder why search for "foo" doesn't return result for "foobar". It's because
default lookup type is exact text, but you probably want `icontains` lookup
field.**
The FilterSet Meta class fields can additionally be set using a Dictionary to
specify multiple ``lookup_type`` filters without significant code duplication::
Additionally, a dictionary can be used to specify multiple lookup expressions
for each field::
import django_filters
class ProductFilter(django_filters.FilterSet):
class Meta:
model = Product
fields = {'price': ['lt', 'gt'],
'release_date': ['exact'],
}
fields = {
'price': ['lt', 'gt'],
'release_date': ['exact', 'year__gt'],
}
The above would generate 'price__lt', 'price__gt' and 'release_date' filters.
The filter lookup type keyword 'exact' is the default and therefore never added
to a filter name.
The above would generate 'price__lt', 'price__gt', 'release_date', and
'release_date__year__gt' filters.
.. note::
The filter lookup type 'exact' is an implicit default and therefore never
added to a filter name. In the above example, the release date's exact
filter is 'release_date', not 'release_date__exact'.
Items in the ``fields`` sequence in the ``Meta`` class may include
"relationship paths" using Django's ``__`` syntax to filter on fields on a
@ -81,20 +150,9 @@ related model::
model = Product
fields = ['manufacturer__country']
Filters also take any arbitrary keyword arguments which get passed onto the
``django.forms.Field`` initializer. These extra keyword arguments get stored
in ``Filter.extra``, so it's possible to override the initializer of a
``FilterSet`` to add extra ones::
class ProductFilter(django_filters.FilterSet):
class Meta:
model = Product
fields = ['manufacturer']
def __init__(self, *args, **kwargs):
super(ProductFilter, self).__init__(*args, **kwargs)
self.filters['manufacturer'].extra.update(
{'empty_label': 'All Manufacturers'})
Overriding default filters
""""""""""""""""""""""""""
Like ``django.contrib.admin.ModelAdmin``, it is possible to override
default filters for all the models fields of the same kind using
@ -105,18 +163,28 @@ default filters for all the models fields of the same kind using
models.CharField: {
'filter_class': django_filters.CharFilter,
'extra': lambda f: {
'lookup_type': 'icontains',
}
}
'lookup_expr': 'icontains',
},
},
models.BooleanField: {
'filter_class': django_filters.BooleanFilter,
'extra': lambda f: {
'widget': 'forms.CheckboxInput',
},
},
}
class Meta:
model = Product
fields = ['name']
fields = {
'name': ['exact'],
'release_date': ['isnull'],
}
MethodFilter
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Custom filtering with MethodFilter
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you want fine control over each individual filter attribute, you can use
the ``MethodFilter`` filter.
@ -188,7 +256,7 @@ Now we need to write a view::
def product_list(request):
f = ProductFilter(request.GET, queryset=Product.objects.all())
return render_to_response('my_app/template.html', {'filter': f})
return render(request, 'my_app/template.html', {'filter': f})
If a queryset argument isn't provided then all the items in the default manager
of the model will be used.
@ -196,6 +264,7 @@ of the model will be used.
If you want to access the filtered objects in your views, for example if you
want to paginate them, you can do that. They are in f.qs
The URL conf
------------
@ -203,6 +272,7 @@ We need a URL pattern to call the view::
url(r'^list$', views.product_list)
The template
------------
@ -224,113 +294,9 @@ 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
the resulting queryset.
Other Meta options
------------------
Ordering using ``order_by``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can allow the user to control ordering by providing the
``order_by`` argument in the Filter's Meta class. ``order_by`` can be either a
``list`` or ``tuple`` of field names, in which case those are the options, or
it can be a ``bool`` which, if True, indicates that all fields that
the user can filter on can also be sorted on. An example or ordering using a list::
import django_filters
class ProductFilter(django_filters.FilterSet):
price = django_filters.NumberFilter(lookup_type='lt')
class Meta:
model = Product
fields = ['price', 'release_date']
order_by = ['price']
If you want to control the display of items in ``order_by``, you can set it to
a list or tuple of 2-tuples in the format ``(field_name, display_name)``.
This lets you override the displayed names for your ordering fields::
order_by = (
('name', 'Company Name'),
('average_rating', 'Stars'),
)
Note that the default query parameter name used for ordering is ``o``. You
can override this by setting an ``order_by_field`` attribute on the
``FilterSet`` class to the string value you would like to use.
Custom Forms using ``form``
~~~~~~~~~~~~~~~~~~~~~~~~~~~
The inner ``Meta`` class also takes an optional ``form`` argument. This is a
form class from which ``FilterSet.form`` will subclass. This works similar to
the ``form`` option on a ``ModelAdmin.``
Group fields with ``together``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The inner ``Meta`` class also takes an optional ``together`` argument. This
is a list of lists, each containing field names. For convenience can be a
single list/tuple when dealing with a single set of fields. Fields within a
field set must either be all or none present in the request for
``FilterSet.form`` to be valid::
import django_filters
class ProductFilter(django_filters.FilterSet):
class Meta:
model = Product
fields = ['price', 'release_date', 'rating']
together = ['rating', 'price']
Non-Meta options
----------------
Note that these options do not go in the Meta class, they are specified directly
in your FilterSet class.
``strict``
~~~~~~~~~~
The ``strict`` option controls whether results are returned when an invalid
value is specified by the user for any filter field. By default, ``strict`` is
set to ``STRICTNESS.RETURN_NO_RESULTS`` meaning that an empty queryset is
returned if any field contains an invalid value. You can loosen this behavior
by setting ``strict`` to ``STRICTNESS.IGNORE`` which will effectively ignore a
filter field if its value is invalid. A third option of
``STRICTNESS.RAISE_VALIDATION_ERROR`` will cause a ``ValidationError` to be
raised if any field contains an invalid value.
Overriding ``FilterSet`` methods
--------------------------------
``get_ordering_field()``
~~~~~~~~~~~~~~~~~~~~~~~~
If you want to use a custom widget, or in any other way override the ordering
field you can override the ``get_ordering_field()`` method on a ``FilterSet``.
This method just needs to return a Form Field.
Ordering on multiple fields, or other complex orderings can be achieved by
overriding the ``FilterSet.get_order_by()`` method. This is passed the selected
``order_by`` value, and is expected to return an iterable of values to pass to
``QuerySet.order_by``. For example, to sort a ``User`` table by last name, then
first name::
class UserFilter(django_filters.FilterSet):
class Meta:
order_by = (
('username', 'Username'),
('last_name', 'Last Name')
)
def get_order_by(self, order_value):
if order_value == 'last_name':
return ['last_name', 'first_name']
return super(UserFilter, self).get_order_by(order_value)
Generic View
------------
Generic view & configuration
-----------------------------
In addition to the above usage there is also a class-based generic view
included in django-filter, which lives at ``django_filters.views.FilterView``.
@ -365,33 +331,3 @@ to it as the class based view::
The needed template and its context variables will also be the same as the
class-based view above.
Custom lookup types
-------------------
By default django-filter uses Django's ORM built-in field lookups. If you want to globally accept specific lookups you can do the following:
from django_filters import filters
filters.LOOKUP_TYPES = ['gt', 'gte', 'lt', 'lte', 'custom_lookup_type']
Choices help text
-----------------
If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following:
from django_filters import filters
filters.LOOKUP_TYPES = [
('', '---------'),
('exact', 'Is equal to'),
('not_exact', 'Is not equal to'),
('lt', 'Lesser than'),
('gt', 'Greater than'),
('gte', 'Greater than or equal to'),
('lte', 'Lesser than or equal to'),
('startswith', 'Starts with'),
('endswith', 'Ends with'),
('contains', 'Contains'),
('not_contains', 'Does not contain'),
]

View File

@ -1 +0,0 @@
Sphinx

View File

@ -1,4 +1,30 @@
bumpversion
twine==1.5.0
wheel==0.24.0
coverage==3.7.1
alabaster==0.7.7
argh==0.26.1
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
Pygments==2.1.3
pytz==2015.7
PyYAML==3.11
requests==2.9.1
requests-toolbelt==0.6.0
six==1.9.0
snowballstemmer==1.2.1
Sphinx==1.3.6
sphinx-autobuild==0.5.2
sphinx-rtd-theme==0.1.9
tornado==4.2.1
twine==1.6.5
watchdog==0.8.3

View File

@ -6,7 +6,7 @@ f = open('README.rst')
readme = f.read()
f.close()
version = '0.12.0'
version = '0.13.0'
if sys.argv[-1] == 'publish':
if os.system("pip freeze | grep wheel"):
@ -30,7 +30,9 @@ setup(
long_description=readme,
author='Alex Gaynor',
author_email='alex.gaynor@gmail.com',
url='http://github.com/alex/django-filter/tree/master',
maintainer='Carlton Gibson',
maintainer_email='carlton.gibson@noumenal.es',
url='http://github.com/carltongibson/django-filter/tree/master',
packages=find_packages(exclude=['tests']),
package_data = {
'django_filters': [

View File

@ -67,7 +67,7 @@ class AdminUser(User):
@python_2_unicode_compatible
class Comment(models.Model):
text = models.TextField()
author = models.ForeignKey(User, related_name='comments')
author = models.ForeignKey(User, related_name='comments', on_delete=models.CASCADE)
date = models.DateField()
time = models.TimeField()
@ -78,7 +78,7 @@ class Comment(models.Model):
class Article(models.Model):
published = models.DateTimeField()
author = models.ForeignKey(User, null=True)
author = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
def __str__(self):
if self.author_id:
@ -125,7 +125,7 @@ class Company(models.Model):
@python_2_unicode_compatible
class Location(models.Model):
company = models.ForeignKey(Company, related_name='locations')
company = models.ForeignKey(Company, related_name='locations', on_delete=models.CASCADE)
name = models.CharField(max_length=100)
zip_code = models.CharField(max_length=10)
open_days = models.CharField(max_length=7)
@ -141,7 +141,7 @@ class Account(models.Model):
class Profile(models.Model):
account = models.OneToOneField(Account, related_name='profile')
account = models.OneToOneField(Account, related_name='profile', on_delete=models.CASCADE)
likes_coffee = models.BooleanField(default=False)
likes_tea = models.BooleanField(default=False)
@ -169,8 +169,8 @@ class Worker(models.Model):
class HiredWorker(models.Model):
salary = models.IntegerField()
hired_on = models.DateField()
worker = models.ForeignKey(Worker)
business = models.ForeignKey('Business')
worker = models.ForeignKey(Worker, on_delete=models.CASCADE)
business = models.ForeignKey('Business', on_delete=models.CASCADE)
class Business(models.Model):

View File

@ -8,17 +8,33 @@ import unittest
import django
from django import forms
from django.test import TestCase, override_settings
from django.utils.timezone import make_aware
from django.utils.timezone import make_aware, get_default_timezone
from django_filters.widgets import RangeWidget
from django_filters.fields import (
RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField)
Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
)
def to_d(float_value):
return decimal.Decimal('%.2f' % float_value)
class LookupBoolTests(TestCase):
def test_lookup_true(self):
self.assertTrue(Lookup(True, 'exact'))
self.assertTrue(Lookup(1, 'exact'))
self.assertTrue(Lookup('1', 'exact'))
self.assertTrue(Lookup(datetime.now(), 'exact'))
def test_lookup_false(self):
self.assertFalse(Lookup(False, 'exact'))
self.assertFalse(Lookup(0, 'exact'))
self.assertFalse(Lookup('', 'exact'))
self.assertFalse(Lookup(None, 'exact'))
class RangeFieldTests(TestCase):
def test_field(self):
@ -40,16 +56,32 @@ class DateRangeFieldTests(TestCase):
f = DateRangeField()
self.assertEqual(len(f.fields), 2)
@override_settings(USE_TZ=False)
def test_clean(self):
w = RangeWidget()
f = DateRangeField(widget=w)
self.assertEqual(
f.clean(['2015-01-01', '2015-01-10']),
slice(datetime(2015, 1, 1, 0, 0 , 0),
slice(datetime(2015, 1, 1, 0, 0, 0),
datetime(2015, 1, 10, 23, 59, 59, 999999)))
class DateTimeRangeFieldTests(TestCase):
def test_field(self):
f = DateTimeRangeField()
self.assertEqual(len(f.fields), 2)
@override_settings(USE_TZ=False)
def test_clean(self):
w = RangeWidget()
f = DateTimeRangeField(widget=w)
self.assertEqual(
f.clean(['2015-01-01 10:30', '2015-01-10 8:45']),
slice(datetime(2015, 1, 1, 10, 30, 0),
datetime(2015, 1, 10, 8, 45, 0)))
class TimeRangeFieldTests(TestCase):
def test_field(self):
@ -138,7 +170,7 @@ class IsoDateTimeFieldTests(TestCase):
def test_datetime_timezone_awareness(self):
# parsed datetimes should obey USE_TZ
f = IsoDateTimeField()
r = make_aware(self.reference_dt, f.default_timezone)
r = make_aware(self.reference_dt, get_default_timezone())
d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d.tzinfo, tzinfo))
@ -154,10 +186,6 @@ class IsoDateTimeFieldTests(TestCase):
f = IsoDateTimeField()
r = self.reference_dt.replace()
# It's necessary to override this here, since the field class is parsed
# when USE_TZ = True.
f.default_timezone = None
d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601)
self.assertTrue(d.tzinfo is None)
self.assertEqual(d, r - timedelta(hours=1))
@ -165,3 +193,50 @@ class IsoDateTimeFieldTests(TestCase):
d = f.strptime(self.reference_str + "", IsoDateTimeField.ISO_8601)
self.assertTrue(d.tzinfo is None)
self.assertEqual(d, r)
class BaseCSVFieldTests(TestCase):
def setUp(self):
class DecimalCSVField(BaseCSVField, forms.DecimalField):
pass
self.field = DecimalCSVField()
def test_clean(self):
self.assertEqual(self.field.clean(None), None)
self.assertEqual(self.field.clean(''), [])
self.assertEqual(self.field.clean(['1']), [1])
self.assertEqual(self.field.clean(['1', '2']), [1, 2])
self.assertEqual(self.field.clean(['1', '2', '3']), [1, 2, 3])
def test_validation_error(self):
with self.assertRaises(forms.ValidationError):
self.field.clean([''])
with self.assertRaises(forms.ValidationError):
self.field.clean(['a', 'b', 'c'])
class BaseRangeFieldTests(TestCase):
def setUp(self):
class DecimalRangeField(BaseRangeField, forms.DecimalField):
pass
self.field = DecimalRangeField()
def test_clean(self):
self.assertEqual(self.field.clean(None), None)
self.assertEqual(self.field.clean(['1', '2']), [1, 2])
def test_validation_error(self):
with self.assertRaises(forms.ValidationError):
self.field.clean('')
with self.assertRaises(forms.ValidationError):
self.field.clean([''])
with self.assertRaises(forms.ValidationError):
self.field.clean(['1'])
with self.assertRaises(forms.ValidationError):
self.field.clean(['1', '2', '3'])

View File

@ -5,22 +5,27 @@ import datetime
import mock
import unittest
from django.test import TestCase
import django
from django.test import TestCase, override_settings
from django.utils import six
from django.utils.timezone import now
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 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 MethodFilter
from django_filters.filters import MultipleChoiceFilter
from django_filters.filters import ModelMultipleChoiceFilter
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 .models import User
@ -446,9 +451,9 @@ class NumberFilterTests(TestCase):
f = F({'price': 10}, queryset=Book.objects.all())
self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
def test_filtering_with_single_lookup_type(self):
def test_filtering_with_single_lookup_expr(self):
class F(FilterSet):
price = NumberFilter(lookup_type='lt')
price = NumberFilter(lookup_expr='lt')
class Meta:
model = Book
@ -458,7 +463,7 @@ class NumberFilterTests(TestCase):
self.assertQuerysetEqual(
f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title)
def test_filtering_with_single_lookup_type_dictionary(self):
def test_filtering_with_single_lookup_expr_dictionary(self):
class F(FilterSet):
class Meta:
model = Book
@ -468,9 +473,9 @@ class NumberFilterTests(TestCase):
self.assertQuerysetEqual(
f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title)
def test_filtering_with_multiple_lookup_types(self):
def test_filtering_with_multiple_lookup_exprs(self):
class F(FilterSet):
price = NumberFilter(lookup_type=['lt', 'gt'])
price = NumberFilter(lookup_expr=['lt', 'gt'])
class Meta:
model = Book
@ -487,7 +492,7 @@ class NumberFilterTests(TestCase):
lambda o: o.title, ordered=False)
class F(FilterSet):
price = NumberFilter(lookup_type=['lt', 'gt', 'exact'])
price = NumberFilter(lookup_expr=['lt', 'gt', 'exact'])
class Meta:
model = Book
@ -619,6 +624,102 @@ class DateRangeFilterTests(TestCase):
# different things to both user and server.
class DateFromToRangeFilterTests(TestCase):
def test_filtering(self):
adam = User.objects.create(username='adam')
kwargs = {'text': 'test', 'author': adam, 'time': '10:00'}
Comment.objects.create(date=datetime.date(2016, 1, 1), **kwargs)
Comment.objects.create(date=datetime.date(2016, 1, 2), **kwargs)
Comment.objects.create(date=datetime.date(2016, 1, 3), **kwargs)
Comment.objects.create(date=datetime.date(2016, 1, 3), **kwargs)
class F(FilterSet):
published = DateFromToRangeFilter(name='date')
class Meta:
model = Comment
fields = ['date']
results = F(data={
'published_0': '2016-01-02',
'published_1': '2016-01-03'})
self.assertEqual(len(results.qs), 3)
def test_filtering_ignores_time(self):
tz = timezone.get_current_timezone()
Article.objects.create(
published=datetime.datetime(2016, 1, 1, 10, 0, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 2, 12, 45, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 3, 18, 15, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 3, 19, 30, tzinfo=tz))
class F(FilterSet):
published = DateFromToRangeFilter()
class Meta:
model = Article
fields = ['published']
results = F(data={
'published_0': '2016-01-02',
'published_1': '2016-01-03'})
self.assertEqual(len(results.qs), 3)
class DateTimeFromToRangeFilterTests(TestCase):
def test_filtering(self):
tz = timezone.get_current_timezone()
Article.objects.create(
published=datetime.datetime(2016, 1, 1, 10, 0, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 2, 12, 45, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 3, 18, 15, tzinfo=tz))
Article.objects.create(
published=datetime.datetime(2016, 1, 3, 19, 30, tzinfo=tz))
class F(FilterSet):
published = DateTimeFromToRangeFilter()
class Meta:
model = Article
fields = ['published']
results = F(data={
'published_0': '2016-01-02 10:00',
'published_1': '2016-01-03 19:00'})
self.assertEqual(len(results.qs), 2)
class TimeRangeFilterTests(TestCase):
def test_filtering(self):
adam = User.objects.create(username='adam')
kwargs = {
'text': 'test', 'author': adam, 'date': datetime.date.today()}
Comment.objects.create(time='7:30', **kwargs)
Comment.objects.create(time='8:00', **kwargs)
Comment.objects.create(time='9:30', **kwargs)
Comment.objects.create(time='11:00', **kwargs)
class F(FilterSet):
time = TimeRangeFilter()
class Meta:
model = Comment
fields = ['time']
results = F(data={
'time_0': '8:00',
'time_1': '10:00'})
self.assertEqual(len(results.qs), 2)
class AllValuesFilterTests(TestCase):
def test_filtering(self):
@ -1240,6 +1341,189 @@ class NonSymmetricalSelfReferentialRelationshipTests(TestCase):
self.assertQuerysetEqual(f.qs, [2], lambda o: o.pk)
# use naive datetimes, as pytz is required to perform
# date lookups when timezones are involved.
@override_settings(USE_TZ=False)
@unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions")
class TransformedQueryExpressionFilterTests(TestCase):
def test_filtering(self):
now_dt = datetime.datetime.now()
after_5pm = now_dt.replace(hour=18)
before_5pm = now_dt.replace(hour=16)
u = User.objects.create(username='alex')
a = Article.objects.create(author=u, published=after_5pm)
Article.objects.create(author=u, published=before_5pm)
class F(FilterSet):
class Meta:
model = Article
fields = {'published': ['hour__gte']}
qs = Article.objects.all()
f = F({'published__hour__gte': 17}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertQuerysetEqual(f.qs, [a.pk], lambda o: o.pk)
# use naive datetimes, as pytz is required to perform
# date lookups when timezones are involved.
@override_settings(USE_TZ=False)
class CSVFilterTests(TestCase):
def setUp(self):
u1 = User.objects.create(username='alex', status=1)
u2 = User.objects.create(username='jacob', status=2)
User.objects.create(username='aaron', status=2)
User.objects.create(username='carl', status=0)
now_dt = datetime.datetime.now()
after_5pm = now_dt.replace(hour=18)
before_5pm = now_dt.replace(hour=16)
Article.objects.create(author=u1, published=after_5pm)
Article.objects.create(author=u2, published=after_5pm)
Article.objects.create(author=u1, published=before_5pm)
Article.objects.create(author=u2, published=before_5pm)
class UserFilter(FilterSet):
class Meta:
model = User
fields = {
'username': ['in'],
'status': ['in'],
}
class ArticleFilter(FilterSet):
class Meta:
model = Article
fields = {
'author': ['in'],
'published': ['in'],
}
self.user_filter = UserFilter
self.article_filter = ArticleFilter
self.after_5pm = after_5pm.strftime('%Y-%m-%d %H:%M:%S.%f')
self.before_5pm = before_5pm.strftime('%Y-%m-%d %H:%M:%S.%f')
def test_numeric_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)
f = F({'status__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
f = F({'status__in': '0'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
f = F({'status__in': '0,2'}, queryset=qs)
self.assertEqual(len(f.qs), 3)
self.assertEqual(f.count(), 3)
f = F({'status__in': '0,,1'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
f = F({'status__in': '2'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.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)
f = F({'username__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
f = F({'username__in': 'alex'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
f = F({'username__in': 'alex,aaron'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
f = F({'username__in': 'alex,,aaron'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
f = F({'username__in': 'alex,'}, queryset=qs)
self.assertEqual(len(f.qs), 1)
self.assertEqual(f.count(), 1)
def test_datetime_filtering(self):
F = self.article_filter
after = self.after_5pm
before = self.before_5pm
qs = Article.objects.all()
f = F(queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
f = F({'published__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
f = F({'published__in': '%s' % (after, )}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
f = F({'published__in': '%s,%s' % (after, before, )}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
f = F({'published__in': '%s,,%s' % (after, before, )}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
f = F({'published__in': '%s,' % (after, )}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.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)
f = F({'author__in': ''}, queryset=qs)
self.assertEqual(len(f.qs), 0)
self.assertEqual(f.count(), 0)
f = F({'author__in': '1'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
f = F({'author__in': '1,2'}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
f = F({'author__in': '1,,2'}, queryset=qs)
self.assertEqual(len(f.qs), 4)
self.assertEqual(f.count(), 4)
f = F({'author__in': '1,'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)
class MiscFilterSetTests(TestCase):
def setUp(self):

View File

@ -1,21 +1,23 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from datetime import date, time, timedelta
from datetime import date, time, timedelta, datetime
import mock
import warnings
import unittest
import django
from django import forms
from django.test import TestCase
from django.test import TestCase, override_settings
from django_filters import filters
from django_filters.fields import (
Lookup,
RangeField,
DateRangeField,
DateTimeRangeField,
TimeRangeField,
LookupTypeField)
LookupTypeField,
BaseCSVField)
from django_filters.filters import (
Filter,
CharFilter,
@ -32,8 +34,12 @@ from django_filters.filters import (
RangeFilter,
DateRangeFilter,
DateFromToRangeFilter,
DateTimeFromToRangeFilter,
TimeRangeFilter,
AllValuesFilter,
BaseCSVFilter,
BaseInFilter,
BaseRangeFilter,
UUIDFilter,
LOOKUP_TYPES)
@ -44,7 +50,7 @@ class FilterTests(TestCase):
def test_creation(self):
f = Filter()
self.assertEqual(f.lookup_type, 'exact')
self.assertEqual(f.lookup_expr, 'exact')
self.assertEqual(f.exclude, False)
def test_creation_order(self):
@ -64,26 +70,40 @@ class FilterTests(TestCase):
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, 'This is an exclusion filter')
def test_field_with_single_lookup_type(self):
f = Filter(lookup_type='iexact')
@override_settings(FILTERS_HELP_TEXT_FILTER=False)
def test_default_field_settings(self):
f = Filter()
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, '')
@override_settings(FILTERS_HELP_TEXT_EXCLUDE=False)
def test_field_with_exclusion_settings(self):
f = Filter(exclude=True)
field = f.field
self.assertIsInstance(field, forms.Field)
self.assertEqual(field.help_text, '')
def test_field_with_single_lookup_expr(self):
f = Filter(lookup_expr='iexact')
field = f.field
self.assertIsInstance(field, forms.Field)
def test_field_with_none_lookup_type(self):
f = Filter(lookup_type=None)
def test_field_with_none_lookup_expr(self):
f = Filter(lookup_expr=None)
field = f.field
self.assertIsInstance(field, LookupTypeField)
choice_field = field.fields[1]
self.assertEqual(len(choice_field.choices), len(LOOKUP_TYPES))
def test_field_with_lookup_type_and_exlusion(self):
f = Filter(lookup_type=None, exclude=True)
def test_field_with_lookup_expr_and_exlusion(self):
f = Filter(lookup_expr=None, exclude=True)
field = f.field
self.assertIsInstance(field, LookupTypeField)
self.assertEqual(field.help_text, 'This is an exclusion filter')
def test_field_with_list_lookup_type(self):
f = Filter(lookup_type=('istartswith', 'iendswith'))
def test_field_with_list_lookup_expr(self):
f = Filter(lookup_expr=('istartswith', 'iendswith'))
field = f.field
self.assertIsInstance(field, LookupTypeField)
choice_field = field.fields[1]
@ -152,22 +172,22 @@ class FilterTests(TestCase):
def test_filtering_with_list_value(self):
qs = mock.Mock(spec=['filter'])
f = Filter(name='somefield', lookup_type=['some_lookup_type'])
result = f.filter(qs, Lookup('value', 'some_lookup_type'))
qs.filter.assert_called_once_with(somefield__some_lookup_type='value')
f = Filter(name='somefield', lookup_expr=['some_lookup_expr'])
result = f.filter(qs, Lookup('value', 'some_lookup_expr'))
qs.filter.assert_called_once_with(somefield__some_lookup_expr='value')
self.assertNotEqual(qs, result)
def test_filtering_skipped_with_list_value_with_blank(self):
qs = mock.Mock()
f = Filter(name='somefield', lookup_type=['some_lookup_type'])
result = f.filter(qs, Lookup('', 'some_lookup_type'))
f = Filter(name='somefield', lookup_expr=['some_lookup_expr'])
result = f.filter(qs, Lookup('', 'some_lookup_expr'))
self.assertListEqual(qs.method_calls, [])
self.assertEqual(qs, result)
def test_filtering_skipped_with_list_value_with_blank_lookup(self):
return # Now field is required to provide valid lookup_type if it provides any
return # Now field is required to provide valid lookup_expr if it provides any
qs = mock.Mock(spec=['filter'])
f = Filter(name='somefield', lookup_type=None)
f = Filter(name='somefield', lookup_expr=None)
result = f.filter(qs, Lookup('value', ''))
qs.filter.assert_called_once_with(somefield__exact='value')
self.assertNotEqual(qs, result)
@ -187,6 +207,43 @@ class FilterTests(TestCase):
result = qs.distinct.assert_called_once_with()
self.assertNotEqual(qs, result)
def test_lookup_type_deprecation(self):
"""
Make sure user is alerted when using deprecated ``lookup_type``.
"""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
Filter(lookup_type='exact')
self.assertEqual(len(w), 1)
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
class CustomFilterWithBooleanCheckTests(TestCase):
def setUp(self):
super(CustomFilterWithBooleanCheckTests, self).setUp()
class CustomTestFilter(Filter):
def filter(self_, qs, value):
if not value:
return qs
return super(CustomTestFilter, self_).filter(qs, value)
self.test_filter_class = CustomTestFilter
def test_lookup_false(self):
qs = mock.Mock(spec=['filter'])
f = self.test_filter_class(name='somefield')
result = f.filter(qs, Lookup('', 'exact'))
self.assertEqual(qs, result)
def test_lookup_true(self):
qs = mock.Mock(spec=['filter'])
f = self.test_filter_class(name='somefield')
result = f.filter(qs, Lookup('somesearch', 'exact'))
qs.filter.assert_called_once_with(somefield__exact='somesearch')
self.assertNotEqual(qs, result)
class CharFilterTests(TestCase):
@ -240,9 +297,9 @@ class BooleanFilterTests(TestCase):
self.assertListEqual(qs.method_calls, [])
self.assertEqual(qs, result)
def test_filtering_lookup_type(self):
def test_filtering_lookup_expr(self):
qs = mock.Mock(spec=['filter'])
f = BooleanFilter(name='somefield', lookup_type='isnull')
f = BooleanFilter(name='somefield', lookup_expr='isnull')
result = f.filter(qs, True)
qs.filter.assert_called_once_with(somefield__isnull=True)
self.assertNotEqual(qs, result)
@ -503,10 +560,10 @@ class NumericRangeFilterTests(TestCase):
result = f.filter(qs, None)
self.assertEqual(qs, result)
def test_field_with_lookup_type(self):
def test_field_with_lookup_expr(self):
qs = mock.Mock()
value = mock.Mock(start=20, stop=30)
f = NumericRangeFilter(lookup_type=('overlap'))
f = NumericRangeFilter(lookup_expr=('overlap'))
f.filter(qs, value)
qs.filter.assert_called_once_with(None__overlap=(20, 30))
@ -567,10 +624,10 @@ class RangeFilterTests(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=20, stop=30)
f = RangeFilter(lookup_type='gte')
f = RangeFilter(lookup_expr='gte')
f.filter(qs, value)
qs.filter.assert_called_once_with(None__range=(20, 30))
@ -700,15 +757,61 @@ class DateFromToRangeFilterTests(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=date(2015, 4, 7), stop=date(2015, 9, 6))
f = DateFromToRangeFilter(lookup_type='gte')
f = DateFromToRangeFilter(lookup_expr='gte')
f.filter(qs, value)
qs.filter.assert_called_once_with(
None__range=(date(2015, 4, 7), date(2015, 9, 6)))
class DateTimeFromToRangeFilterTests(TestCase):
def test_default_field(self):
f = DateTimeFromToRangeFilter()
field = f.field
self.assertIsInstance(field, DateTimeRangeField)
def test_filtering_range(self):
qs = mock.Mock(spec=['filter'])
value = mock.Mock(
start=datetime(2015, 4, 7, 8, 30), stop=datetime(2015, 9, 6, 11, 45))
f = DateTimeFromToRangeFilter()
f.filter(qs, value)
qs.filter.assert_called_once_with(
None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45)))
def test_filtering_start(self):
qs = mock.Mock(spec=['filter'])
value = mock.Mock(start=datetime(2015, 4, 7, 8, 30), stop=None)
f = DateTimeFromToRangeFilter()
f.filter(qs, value)
qs.filter.assert_called_once_with(None__gte=datetime(2015, 4, 7, 8, 30))
def test_filtering_stop(self):
qs = mock.Mock(spec=['filter'])
value = mock.Mock(start=None, stop=datetime(2015, 9, 6, 11, 45))
f = DateTimeFromToRangeFilter()
f.filter(qs, value)
qs.filter.assert_called_once_with(None__lte=datetime(2015, 9, 6, 11, 45))
def test_filtering_skipped_with_none_value(self):
qs = mock.Mock(spec=['filter'])
f = DateTimeFromToRangeFilter()
result = f.filter(qs, None)
self.assertEqual(qs, result)
def test_filtering_ignores_lookup_type(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.filter(qs, value)
qs.filter.assert_called_once_with(
None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45)))
class TimeRangeFilterTests(TestCase):
def test_default_field(self):
@ -744,10 +847,10 @@ class TimeRangeFilterTests(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=time(10, 15), stop=time(12, 30))
f = TimeRangeFilter(lookup_type='gte')
f = TimeRangeFilter(lookup_expr='gte')
f.filter(qs, value)
qs.filter.assert_called_once_with(
None__range=(time(10, 15), time(12, 30)))
@ -772,7 +875,7 @@ class AllValuesFilterTests(TestCase):
class LookupTypesTests(TestCase):
def test_custom_lookup_types(self):
def test_custom_lookup_exprs(self):
filters.LOOKUP_TYPES = [
('', '---------'),
('exact', 'Is equal to'),
@ -787,12 +890,97 @@ class LookupTypesTests(TestCase):
('not_contains', 'Does not contain'),
]
f = Filter(lookup_type=None)
f = Filter(lookup_expr=None)
field = f.field
choice_field = field.fields[1]
choices = choice_field.choices
all_choices = choice_field.choices
self.assertIsInstance(field, LookupTypeField)
self.assertEqual(choices, filters.LOOKUP_TYPES)
self.assertEqual(choices[1][0], 'exact')
self.assertEqual(choices[1][1], 'Is equal to')
self.assertEqual(all_choices, filters.LOOKUP_TYPES)
self.assertEqual(all_choices[1][0], 'exact')
self.assertEqual(all_choices[1][1], 'Is equal to')
custom_f = Filter(lookup_expr=('endswith', 'not_contains'))
custom_field = custom_f.field
custom_choice_field = custom_field.fields[1]
my_custom_choices = custom_choice_field.choices
available_lookup_exprs = [
('endswith', 'Ends with'),
('not_contains', 'Does not contain'),
]
self.assertIsInstance(custom_field, LookupTypeField)
self.assertEqual(my_custom_choices, available_lookup_exprs)
self.assertEqual(my_custom_choices[0][0], 'endswith')
self.assertEqual(my_custom_choices[0][1], 'Ends with')
self.assertEqual(my_custom_choices[1][0], 'not_contains')
self.assertEqual(my_custom_choices[1][1], 'Does not contain')
class CSVFilterTests(TestCase):
def setUp(self):
class NumberInFilter(BaseCSVFilter, NumberFilter):
pass
class DateTimeYearInFilter(BaseCSVFilter, DateTimeFilter):
pass
self.number_in = NumberInFilter(lookup_expr='in')
self.datetimeyear_in = DateTimeYearInFilter(lookup_expr='year__in')
def test_default_field(self):
f = BaseCSVFilter()
field = f.field
self.assertIsInstance(field, forms.Field)
def test_concrete_field(self):
field = self.number_in.field
self.assertIsInstance(field, forms.DecimalField)
self.assertIsInstance(field, BaseCSVField)
self.assertEqual(field.__class__.__name__, 'DecimalInField')
field = self.datetimeyear_in.field
self.assertIsInstance(field, forms.DateTimeField)
self.assertIsInstance(field, BaseCSVField)
self.assertEqual(field.__class__.__name__, 'DateTimeYearInField')
def test_filtering(self):
qs = mock.Mock(spec=['filter'])
f = self.number_in
f.filter(qs, [1, 2])
qs.filter.assert_called_once_with(None__in=[1, 2])
def test_filtering_skipped_with_none_value(self):
qs = mock.Mock(spec=['filter'])
f = self.number_in
result = f.filter(qs, None)
self.assertEqual(qs, result)
def test_field_with_lookup_expr(self):
qs = mock.Mock()
f = self.datetimeyear_in
f.filter(qs, [1, 2])
qs.filter.assert_called_once_with(None__year__in=[1, 2])
class BaseInFilterTests(TestCase):
def test_filtering(self):
class NumberInFilter(BaseInFilter, NumberFilter):
pass
qs = mock.Mock(spec=['filter'])
f = NumberInFilter()
f.filter(qs, [1, 2])
qs.filter.assert_called_once_with(None__in=[1, 2])
class BaseRangeFilterTests(TestCase):
def test_filtering(self):
class NumberInFilter(BaseRangeFilter, NumberFilter):
pass
qs = mock.Mock(spec=['filter'])
f = NumberInFilter()
f.filter(qs, [1, 2])
qs.filter.assert_called_once_with(None__range=[1, 2])

View File

@ -4,21 +4,28 @@ import mock
import unittest
import django
from django.core.exceptions import ValidationError
from django.db import models
from django.test import TestCase
from django_filters.filterset import FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
from django_filters.filterset import get_model_field
from django_filters.filterset import STRICTNESS
from django_filters.filters import BooleanFilter
from django_filters.filters import CharFilter
from django_filters.filters import NumberFilter
from django_filters.filters import ChoiceFilter
from django_filters.filters import ModelChoiceFilter
from django_filters.filters import ModelMultipleChoiceFilter
from django_filters.filters import UUIDFilter
from django_filters.filters import BaseInFilter
from django_filters.filters import BaseRangeFilter
from django_filters.widgets import BooleanWidget
from .models import User
from .models import AdminUser
from .models import Article
from .models import Book
from .models import Profile
from .models import Comment
@ -48,14 +55,6 @@ class HelperMethodsTests(TestCase):
def test_get_declared_filters(self):
pass
def test_get_model_field_none(self):
result = get_model_field(User, 'unknown__name')
self.assertIsNone(result)
def test_get_model_field(self):
result = get_model_field(Business, 'hiredworker__worker')
self.assertEqual(result, HiredWorker._meta.get_field('worker'))
@unittest.skip('todo')
def test_filters_for_model(self):
pass
@ -180,11 +179,57 @@ class FilterSetFilterForFieldTests(TestCase):
self.assertIsNotNone(result.extra['queryset'])
self.assertEqual(result.extra['queryset'].model, Worker)
@unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions")
def test_transformed_lookup_expr(self):
f = Comment._meta.get_field('date')
result = FilterSet.filter_for_field(f, 'date', 'year__gte')
self.assertIsInstance(result, NumberFilter)
self.assertEqual(result.name, 'date')
@unittest.skip('todo')
def test_filter_overrides(self):
pass
class FilterSetFilterForLookupTests(TestCase):
def test_filter_for_ISNULL_lookup(self):
f = Article._meta.get_field('author')
result, params = FilterSet.filter_for_lookup(f, 'isnull')
self.assertEqual(result, BooleanFilter)
self.assertDictEqual(params, {})
def test_filter_for_IN_lookup(self):
f = Article._meta.get_field('author')
result, params = FilterSet.filter_for_lookup(f, 'in')
self.assertTrue(issubclass(result, ModelChoiceFilter))
self.assertTrue(issubclass(result, BaseInFilter))
self.assertEqual(params['to_field_name'], 'id')
def test_filter_for_RANGE_lookup(self):
f = Article._meta.get_field('author')
result, params = FilterSet.filter_for_lookup(f, 'range')
self.assertTrue(issubclass(result, ModelChoiceFilter))
self.assertTrue(issubclass(result, BaseRangeFilter))
self.assertEqual(params['to_field_name'], 'id')
def test_isnull_with_filter_overrides(self):
class OFilterSet(FilterSet):
filter_overrides = {
models.BooleanField: {
'filter_class': BooleanFilter,
'extra': lambda f: {
'widget': BooleanWidget,
},
},
}
f = Article._meta.get_field('author')
result, params = OFilterSet.filter_for_lookup(f, 'isnull')
self.assertEqual(result, BooleanFilter)
self.assertEqual(params['widget'], BooleanWidget)
class FilterSetFilterForReverseFieldTests(TestCase):
def test_reverse_o2o_relationship(self):
@ -327,13 +372,15 @@ class FilterSetClassCreationTests(TestCase):
self.assertTrue(checkItemsEqual(list(F.base_filters), expected_list))
def test_meta_fields_containing_unknown(self):
with self.assertRaises(TypeError):
with self.assertRaises(TypeError) as excinfo:
class F(FilterSet):
username = CharFilter()
class Meta:
model = Book
fields = ('username', 'price', 'other')
self.assertEqual(excinfo.exception.args, (
"Meta.fields contains a field that isn't defined on this FilterSet: other",))
def test_meta_fields_dictionary_containing_unknown(self):
with self.assertRaises(TypeError):
@ -537,7 +584,21 @@ class FilterSetOrderingTests(TestCase):
def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self):
class F(FilterSet):
strict = False
strict = STRICTNESS.IGNORE
class Meta:
model = User
fields = ['username', 'status']
order_by = ['status']
self.assertFalse(F.strict)
f = F({'o': 'username'}, queryset=self.qs)
self.assertQuerysetEqual(
f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)
def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self):
class F(FilterSet):
strict = STRICTNESS.RAISE_VALIDATION_ERROR
class Meta:
model = User
@ -545,6 +606,14 @@ class FilterSetOrderingTests(TestCase):
order_by = ['status']
f = F({'o': 'username'}, queryset=self.qs)
with self.assertRaises(ValidationError) as excinfo:
f.qs.all()
self.assertEqual(excinfo.exception.message_dict,
{'o': ['Select a valid choice. username is not one '
'of the available choices.']})
# No default order_by should get applied.
f = F({}, queryset=self.qs)
self.assertQuerysetEqual(
f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)

166
tests/test_utils.py Normal file
View File

@ -0,0 +1,166 @@
import unittest
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 .models import User
from .models import Article
from .models import Book
from .models import HiredWorker
from .models import Business
class GetModelFieldTests(TestCase):
def test_non_existent_field(self):
result = get_model_field(User, 'unknown__name')
self.assertIsNone(result)
def test_related_field(self):
result = get_model_field(Business, 'hiredworker__worker')
self.assertEqual(result, HiredWorker._meta.get_field('worker'))
class ResolveFieldTests(TestCase):
def test_resolve_plain_lookups(self):
"""
Check that the standard query terms can be correctly resolved.
eg, an 'EXACT' lookup on a user's username
"""
model_field = User._meta.get_field('username')
lookups = model_field.class_lookups.keys()
# This is simple - the final ouput of an untransformed field is itself.
# The lookups are the default lookups registered to the class.
for term in lookups:
field, lookup = resolve_field(model_field, term)
self.assertIsInstance(field, models.CharField)
self.assertEqual(lookup, term)
def test_resolve_forward_related_lookups(self):
"""
Check that lookups can be resolved for related fields
in the forwards direction.
"""
lookups = ['exact', 'gte', 'gt', 'lte', 'lt', 'in', 'isnull', ]
# ForeignKey
model_field = Article._meta.get_field('author')
for term in lookups:
field, lookup = resolve_field(model_field, term)
self.assertIsInstance(field, models.ForeignKey)
self.assertEqual(lookup, term)
# ManyToManyField
model_field = User._meta.get_field('favorite_books')
for term in lookups:
field, lookup = resolve_field(model_field, term)
self.assertIsInstance(field, models.ManyToManyField)
self.assertEqual(lookup, term)
@unittest.skipIf(django.VERSION < (1, 9), "version does not reverse lookups")
def test_resolve_reverse_related_lookups(self):
"""
Check that lookups can be resolved for related fields
in the reverse direction.
"""
lookups = ['exact', 'gte', 'gt', 'lte', 'lt', 'in', 'isnull', ]
# ManyToOneRel
model_field = User._meta.get_field('article')
for term in lookups:
field, lookup = resolve_field(model_field, term)
self.assertIsInstance(field, models.ManyToOneRel)
self.assertEqual(lookup, term)
# ManyToManyRel
model_field = Book._meta.get_field('lovers')
for term in lookups:
field, lookup = resolve_field(model_field, term)
self.assertIsInstance(field, models.ManyToManyRel)
self.assertEqual(lookup, term)
@unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions")
def test_resolve_transformed_lookups(self):
"""
Check that chained field transforms are correctly resolved.
eg, a 'date__year__gte' lookup on an article's 'published' timestamp.
"""
# Use a DateTimeField, so we can check multiple transforms.
# eg, date__year__gte
model_field = Article._meta.get_field('published')
standard_lookups = [
'exact',
'iexact',
'gte',
'gt',
'lte',
'lt',
]
date_lookups = [
'year',
'month',
'day',
'week_day',
]
datetime_lookups = date_lookups + [
'hour',
'minute',
'second',
]
# ex: 'date__gt'
for lookup in standard_lookups:
field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join(['date', lookup]))
self.assertIsInstance(field, models.DateField)
self.assertEqual(resolved_lookup, lookup)
# ex: 'year__iexact'
for part in datetime_lookups:
for lookup in standard_lookups:
field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join([part, lookup]))
self.assertIsInstance(field, models.IntegerField)
self.assertEqual(resolved_lookup, lookup)
# ex: 'date__year__lte'
for part in date_lookups:
for lookup in standard_lookups:
field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join(['date', part, lookup]))
self.assertIsInstance(field, models.IntegerField)
self.assertEqual(resolved_lookup, lookup)
@unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions")
def test_resolve_implicit_exact_lookup(self):
# Use a DateTimeField, so we can check multiple transforms.
# eg, date__year__gte
model_field = Article._meta.get_field('published')
field, lookup = resolve_field(model_field, 'date')
self.assertIsInstance(field, models.DateField)
self.assertEqual(lookup, 'exact')
field, lookup = resolve_field(model_field, 'date__year')
self.assertIsInstance(field, models.IntegerField)
self.assertEqual(lookup, 'exact')
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')
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')

View File

@ -5,6 +5,7 @@ from django.test import TestCase
from django.forms import TextInput, Select
from django_filters.widgets import BooleanWidget
from django_filters.widgets import CSVWidget
from django_filters.widgets import RangeWidget
from django_filters.widgets import LinkWidget
from django_filters.widgets import LookupTypeWidget
@ -148,6 +149,15 @@ class RangeWidgetTests(TestCase):
class BooleanWidgetTests(TestCase):
"""
"""
def test_widget_render(self):
w = BooleanWidget()
self.assertHTMLEqual(w.render('price', ''), """
<select name="price">
<option selected="selected" value="">Unknown</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>""")
def test_widget_value_from_datadict(self):
"""
"""
@ -163,3 +173,48 @@ class BooleanWidgetTests(TestCase):
result = w.value_from_datadict({}, {}, 'active')
self.assertEqual(result, None)
class CSVWidgetTests(TestCase):
def test_widget(self):
w = CSVWidget()
self.assertHTMLEqual(w.render('price', None), """
<input type="text" name="price" />""")
self.assertHTMLEqual(w.render('price', ''), """
<input type="text" name="price" />""")
self.assertHTMLEqual(w.render('price', '1,2'), """
<input type="text" name="price" value="1,2" />""")
self.assertHTMLEqual(w.render('price', ['1', '2']), """
<input type="text" name="price" value="1,2" />""")
self.assertHTMLEqual(w.render('price', [1, 2]), """
<input type="text" name="price" value="1,2" />""")
def test_widget_value_from_datadict(self):
w = CSVWidget()
data = {'price': None}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, None)
data = {'price': '1'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1'])
data = {'price': '1,2'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1', '2'])
data = {'price': '1,,2'}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, ['1', '', '2'])
data = {'price': ''}
result = w.value_from_datadict(data, {}, 'price')
self.assertEqual(result, [''])
result = w.value_from_datadict({}, {}, 'price')
self.assertEqual(result, None)

View File

@ -20,6 +20,6 @@ setenv =
deps =
django17: django>=1.7.0,<1.8.0
django18: django>=1.8.0,<1.9.0
django19: https://www.djangoproject.com/download/1.9a1/tarball/
django19: django>=1.9.0,<1.10.0
django-latest: https://github.com/django/django/archive/master.tar.gz
-rrequirements/test.txt