commit
44af395d9b
|
@ -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]+))?
|
||||
|
|
14
CHANGES.rst
14
CHANGES.rst
|
@ -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
|
||||
|
|
|
@ -8,3 +8,4 @@ recursive-include docs *
|
|||
recursive-include requirements *
|
||||
recursive-include tests *
|
||||
recursive-include django_filters/locale *
|
||||
prune docs/_build
|
|
@ -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::
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 "全部"
|
|
@ -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
|
|
@ -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)
|
||||
|
|
10
docs/conf.py
10
docs/conf.py
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -14,7 +14,9 @@ Contents:
|
|||
|
||||
install
|
||||
usage
|
||||
ref/filterset
|
||||
ref/filters
|
||||
ref/fields
|
||||
ref/widgets
|
||||
ref/settings
|
||||
tests
|
||||
|
|
|
@ -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``.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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``.
|
||||
|
|
|
@ -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)
|
|
@ -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``.
|
|
@ -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.
|
||||
|
|
288
docs/usage.txt
288
docs/usage.txt
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Sphinx
|
|
@ -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
|
||||
|
|
6
setup.py
6
setup.py
|
@ -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': [
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
||||
|
|
2
tox.ini
2
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue