Merge pull request #303 from alex/develop

Version 0.12.0 Release
This commit is contained in:
Carlton Gibson 2016-01-07 20:50:29 +01:00
commit d2bda5a3b9
33 changed files with 594 additions and 259 deletions

View File

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

View File

@ -1,21 +1,18 @@
language: python
sudo: false
python:
- "2.6"
- "2.7"
- "3.2"
- "3.3"
- "3.4"
#- "pypy"
#- "pypy3"
- "3.5"
env:
- DJANGO='https://github.com/django/django/archive/master.tar.gz'
- DJANGO='django>=1.9.0,<1.10.0'
- DJANGO='django>=1.8.0,<1.9.0'
- DJANGO='django>=1.7.0,<1.8.0'
- DJANGO='django>=1.6.0,<1.7.0'
- DJANGO='django>=1.5.0,<1.6.0'
- DJANGO='django>=1.4.0,<1.5.0'
install:
- travis_retry pip install $DJANGO
@ -31,21 +28,13 @@ notifications:
matrix:
exclude:
- python: "3.2"
env: DJANGO='django>=1.4.0,<1.5.0'
- python: "3.3"
env: DJANGO='django>=1.4.0,<1.5.0'
- python: "3.4"
env: DJANGO='django>=1.4.0,<1.5.0'
#- python: "pypy"
# env: DJANGO='django>=1.4.0,<1.5.0'
#- python: "pypy3"
# env: DJANGO='django>=1.4.0,<1.5.0'
- python: "2.6"
env: DJANGO='django>=1.7.0,<1.8.0'
- python: "2.6"
env: DJANGO='django>=1.8.0,<1.9.0'
- python: "2.6"
env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
- python: "3.2"
env: DJANGO='django>=1.9.0,<1.10.0'
- python: "3.3"
env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
- python: "3.3"
env: DJANGO='django>=1.9.0,<1.10.0'
allow_failures:
- env: DJANGO='https://github.com/django/django/archive/master.tar.gz'
fast_finish: true

View File

@ -1,3 +1,29 @@
Version 0.12.0 (2016-01-07)
---------------------------
* FEATURE: Add support for custom ORM lookup types #221
* FEATURE: Add JavaScript friendly BooleanWidget #270
* FIXED: (More) Compatability with Django 1.8 and Django 1.9+
* BREAKING CHANGE: custom filter names are now also be used for ordering #230
If you use ordering on a field you defined as custom filter with custom
name, you should now use the filter name as ordering key as well.
Eg. For a filter like :
class F(FilterSet):
account = CharFilter(name='username')
class Meta:
model = User
fields = ['account', 'status']
order_by = True
Before, ordering was like `?o=username`. Since 0.12.0 it's `o=account`.
Version 0.11.0 (2015-08-14)
---------------------------

View File

@ -12,8 +12,8 @@ Full documentation on `read the docs`_.
Requirements
------------
* Python 2.6+
* Django 1.4.5+
* Python 2.7, 3.2, 3.3, 3.4, 3.5
* Django 1.8, 1.9
Installation
------------
@ -50,7 +50,7 @@ And then in your view you could do::
Django-filters additionally supports specifying FilterSet fields using a
dictionary to specify filters with lookup types::
import django_filters
class ProductFilter(django_filters.FilterSet):

View File

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

View File

@ -5,13 +5,11 @@ 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
# TODO: Remove this once Django 1.4 is EOL.
try:
from django.utils.encoding import force_str
except ImportError:
force_str = None
from django.utils.encoding import force_str
from .widgets import RangeWidget, LookupTypeWidget
@ -61,6 +59,8 @@ class TimeRangeField(RangeField):
Lookup = namedtuple('Lookup', ('value', 'lookup_type'))
class LookupTypeField(forms.MultiValueField):
def __init__(self, field, lookup_choices, *args, **kwargs):
fields = (
@ -75,7 +75,7 @@ class LookupTypeField(forms.MultiValueField):
super(LookupTypeField, self).__init__(fields, *args, **kwargs)
def compress(self, data_list):
if len(data_list)==2:
if len(data_list) == 2:
return Lookup(value=data_list[0], lookup_type=data_list[1] or 'exact')
return Lookup(value=None, lookup_type='exact')
@ -91,15 +91,21 @@ 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):
# TODO: Remove this once Django 1.4 is EOL.
if force_str is not None:
value = force_str(value)
value = force_str(value)
if format == self.ISO_8601:
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 super(IsoDateTimeField, self).strptime(value, format)

View File

@ -33,7 +33,7 @@ class Filter(object):
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_type='exact', required=False, distinct=False, exclude=False, **kwargs):
self.name = name
self.label = label
if action:
@ -62,18 +62,27 @@ class Filter(object):
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.lookup_type is None:
lookup = [(x, x) for x in LOOKUP_TYPES]
else:
lookup = [
(x, x) for x in LOOKUP_TYPES if x in self.lookup_type]
lookup = []
for x in LOOKUP_TYPES:
if isinstance(x, (list, tuple)) and len(x) == 2:
choice = (x[0], x[1])
else:
choice = (x, x)
if self.lookup_type is None:
lookup.append(choice)
elif x in self.lookup_type:
lookup.append(choice)
self._field = LookupTypeField(self.field_class(
required=self.required, widget=self.widget, **self.extra),
lookup, required=self.required, label=self.label, help_text=help_text)
else:
self._field = self.field_class(required=self.required,
label=self.label, widget=self.widget,
help_text=help_text, **self.extra)
label=self.label, widget=self.widget,
help_text=help_text, **self.extra)
return self._field
def filter(self, qs, value):
@ -84,9 +93,9 @@ class Filter(object):
lookup = self.lookup_type
if value in ([], (), {}, None, ''):
return qs
qs = self.get_method(qs)(**{'%s__%s' % (self.name, lookup): value})
if self.distinct:
qs = qs.distinct()
qs = self.get_method(qs)(**{'%s__%s' % (self.name, lookup): value})
return qs
@ -97,11 +106,6 @@ class CharFilter(Filter):
class BooleanFilter(Filter):
field_class = forms.NullBooleanField
def filter(self, qs, value):
if value is not None:
return self.get_method(qs)(**{self.name: value})
return qs
class ChoiceFilter(Filter):
field_class = forms.ChoiceField
@ -111,6 +115,10 @@ class TypedChoiceFilter(Filter):
field_class = forms.TypedChoiceField
class UUIDFilter(Filter):
field_class = forms.UUIDField
class MultipleChoiceFilter(Filter):
"""
This filter preforms OR(by default) or AND(using conjoined=True) query
@ -156,7 +164,7 @@ class MultipleChoiceFilter(Filter):
return False
def filter(self, qs, value):
value = value or () # Make sure we have an iterable
value = value or () # Make sure we have an iterable
if self.is_noop(qs, value):
return qs
@ -185,6 +193,7 @@ class DateFilter(Filter):
class DateTimeFilter(Filter):
field_class = forms.DateTimeField
class IsoDateTimeFilter(DateTimeFilter):
"""
Uses IsoDateTimeField to support filtering on ISO 8601 formated datetimes.
@ -197,6 +206,7 @@ class IsoDateTimeFilter(DateTimeFilter):
"""
field_class = IsoDateTimeField
class TimeFilter(Filter):
field_class = forms.TimeField
@ -234,19 +244,19 @@ class RangeFilter(Filter):
def filter(self, qs, value):
if value:
if value.start is not None and value.stop is not None:
lookup = '%s__range' % self.name
return self.get_method(qs)(**{lookup: (value.start, value.stop)})
else:
if value.start is not None:
qs = self.get_method(qs)(**{'%s__gte'%self.name:value.start})
if value.stop is not None:
qs = self.get_method(qs)(**{'%s__lte'%self.name:value.stop})
if value.start is not None and value.stop is not None:
lookup = '%s__range' % self.name
return self.get_method(qs)(**{lookup: (value.start, value.stop)})
else:
if value.start is not None:
qs = self.get_method(qs)(**{'%s__gte' % self.name: value.start})
if value.stop is not None:
qs = self.get_method(qs)(**{'%s__lte' % self.name: value.stop})
return qs
_truncate = lambda dt: dt.replace(hour=0, minute=0, second=0)
def _truncate(dt):
return dt.replace(hour=0, minute=0, second=0)
class DateRangeFilter(ChoiceFilter):
@ -285,7 +295,10 @@ class DateRangeFilter(ChoiceFilter):
value = int(value)
except (ValueError, TypeError):
value = ''
return self.options[value][1](qs, self.name)
qs = self.options[value][1](qs, self.name)
if self.distinct:
qs = qs.distinct()
return qs
class DateFromToRangeFilter(RangeFilter):

View File

@ -1,54 +1,29 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import types
import copy
import re
from collections import OrderedDict
from django import forms
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 sys import version_info
try:
from django.db.models.constants import LOOKUP_SEP
except ImportError: # pragma: nocover
# Django < 1.5 fallback
from django.db.models.sql.constants import LOOKUP_SEP # noqa
try:
from collections import OrderedDict
except ImportError: # pragma: nocover
# Django < 1.5 fallback
from django.utils.datastructures import SortedDict as OrderedDict # noqa
try:
from django.db.models.related import RelatedObject as ForeignObjectRel
except ImportError: # pragma: nocover
# Django >= 1.8 replaces RelatedObject with ForeignObjectRel
from django.db.models.fields.related import ForeignObjectRel
from .filters import (Filter, CharFilter, BooleanFilter,
ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
ModelMultipleChoiceFilter, NumberFilter)
ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter)
ORDER_BY_FIELD = 'o'
# There is a bug with deepcopy in 2.6, patch if we are running python < 2.7
# http://bugs.python.org/issue1515
if version_info < (2, 7, 0):
def _deepcopy_method(x, memo):
return type(x)(x.im_func, copy.deepcopy(x.im_self, memo), x.im_class)
copy._deepcopy_dispatch[types.MethodType] = _deepcopy_method
class STRICTNESS(object):
"""
Values of False & True chosen for backward compatability reasons.
@ -86,21 +61,15 @@ def get_model_field(model, f):
opts = model._meta
for name in parts[:-1]:
try:
rel = opts.get_field_by_name(name)[0]
rel = opts.get_field(name)
except FieldDoesNotExist:
return None
if isinstance(rel, ForeignObjectRel):
if hasattr(rel, "related_model"):
# django >= 1.8 (ForeignObjectRel)
opts = rel.related_model._meta
else:
# django < 1.8 (RelatedObject)
opts = rel.opts
opts = rel.related_model._meta
else:
model = rel.rel.to
opts = model._meta
opts = rel.rel.to._meta
try:
rel, model, direct, m2m = opts.get_field_by_name(parts[-1])
rel = opts.get_field(parts[-1])
except FieldDoesNotExist:
return None
return rel
@ -112,7 +81,7 @@ def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
opts = model._meta
if fields is None:
fields = [f.name for f in sorted(opts.fields + opts.many_to_many)
if not isinstance(f, models.AutoField)]
if not isinstance(f, models.AutoField)]
# Loop through the list of fields.
for f in fields:
# Skip the field if excluded.
@ -149,18 +118,18 @@ def filters_for_model(model, fields=None, exclude=None, filter_for_field=None,
def get_full_clean_override(together):
def full_clean(form):
def add_error(message):
try:
form.add_error(None, message)
except AttributeError:
form._errors[NON_FIELD_ERRORS] = message
def all_valid(fieldset):
cleaned_data = form.cleaned_data
count = len([i for i in fieldset if cleaned_data.get(i)])
return 0 < count < len(fieldset)
super(form.__class__, form).full_clean()
message = 'Following fields must be together: %s'
if isinstance(together[0], (list, tuple)):
@ -181,7 +150,7 @@ class FilterSetOptions(object):
self.order_by = getattr(options, 'order_by', False)
self.form = getattr(options, 'form', forms.Form)
self.together = getattr(options, 'together', None)
@ -211,7 +180,7 @@ class FilterSetMetaclass(type):
if None in filters.values():
raise TypeError("Meta.fields contains a field that isn't defined "
"on this FilterSet")
"on this FilterSet")
new_class.declared_filters = declared_filters
new_class.base_filters = filters
@ -296,12 +265,15 @@ FILTER_FOR_DBFIELD_DEFAULTS = {
models.URLField: {
'filter_class': CharFilter,
},
models.IPAddressField: {
models.GenericIPAddressField: {
'filter_class': CharFilter,
},
models.CommaSeparatedIntegerField: {
'filter_class': CharFilter,
},
models.UUIDField: {
'filter_class': UUIDFilter,
},
}
@ -335,7 +307,7 @@ class BaseFilterSet(object):
yield obj
def __len__(self):
return len(self.qs)
return self.qs.count()
def __getitem__(self, key):
return self.qs[key]
@ -420,16 +392,21 @@ class BaseFilterSet(object):
# e.g. (('field', 'Display name'), ...)
choices = [(f[0], f[1]) for f in self._meta.order_by]
else:
choices = [(f, _('%s (descending)' % capfirst(f[1:])) if f[0] == '-' else capfirst(f))
for f in self._meta.order_by]
choices = []
for f in self._meta.order_by:
if f[0] == '-':
label = _('%s (descending)' % capfirst(f[1:]))
else:
label = capfirst(f)
choices.append((f, label))
else:
# add asc and desc field names
# use the filter's label if provided
choices = []
for f, fltr in self.filters.items():
choices.extend([
(fltr.name or f, fltr.label or capfirst(f)),
("-%s" % (fltr.name or f), _('%s (descending)' % (fltr.label or capfirst(f))))
(f, fltr.label or capfirst(f)),
("-%s" % (f), _('%s (descending)' % (fltr.label or capfirst(f))))
])
return forms.ChoiceField(label=_("Ordering"), required=False,
choices=choices)
@ -441,6 +418,15 @@ class BaseFilterSet(object):
return self._ordering_field
def get_order_by(self, order_choice):
re_ordering_field = re.compile(r'(?P<inverse>\-?)(?P<field>.*)')
m = re.match(re_ordering_field, order_choice)
inverted = m.group('inverse')
filter_api_name = m.group('field')
_filter = self.filters.get(filter_api_name, None)
if _filter and filter_api_name != _filter.name:
return [inverted + _filter.name]
return [order_choice]
@classmethod

Binary file not shown.

View File

@ -0,0 +1,47 @@
# Django Filter translation.
# Copyright (C) 2013
# This file is distributed under the same license as the django_filter package.
# Gonzalo Bustos, 2015.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-07-05 19:24+0200\n"
"PO-Revision-Date: 2015-10-11 20:53-0300\n"
"Last-Translator: Gonzalo Bustos\n"
"Language-Team: Spanish (Argentina)\n"
"Language: es_AR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.6.10\n"
#: filters.py:51
msgid "This is an exclusion filter"
msgstr "Este es un filtro de exclusión"
#: filters.py:158
msgid "Any date"
msgstr "Cualquier fecha"
#: filters.py:159
msgid "Today"
msgstr "Hoy"
#: filters.py:164
msgid "Past 7 days"
msgstr "Últimos 7 días"
#: filters.py:168
msgid "This month"
msgstr "Este mes"
#: filters.py:172
msgid "This year"
msgstr "Este año"
#: widgets.py:63
msgid "All"
msgstr "Todos"

Binary file not shown.

View File

@ -0,0 +1,65 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django_filters 0.0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-07-25 01:24+0200\n"
"PO-Revision-Date: 2015-07-25 01:27+0100\n"
"Last-Translator: Adam Dobrawy <naczelnik@jawnosc.tk>\n"
"Language-Team: Adam Dobrawy <naczelnik@jawnosc.tk>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2);\n"
"X-Generator: Poedit 1.5.4\n"
"Language: pl_PL\n"
#: filters.py:56
msgid "This is an exclusion filter"
msgstr "Jest to filtr wykluczający"
#: filters.py:56
msgid "Filter"
msgstr "Filter"
#: filters.py:226
msgid "Any date"
msgstr "Dowolna data"
#: filters.py:227
msgid "Today"
msgstr "Dziś"
#: filters.py:232
msgid "Past 7 days"
msgstr "Ostatnie 7 dni"
#: filters.py:236
msgid "This month"
msgstr "Ten miesiąc"
#: filters.py:240
msgid "This year"
msgstr "Ten rok"
#: filters.py:243
msgid "Yesterday"
msgstr "Wczoraj"
#: filterset.py:423 filterset.py:432
#, python-format
msgid "%s (descending)"
msgstr "%s (malejąco)"
#: filterset.py:434
msgid "Ordering"
msgstr "Sortowanie"
#: widgets.py:63
msgid "All"
msgstr "Wszystko"

View File

@ -10,10 +10,7 @@ except:
from django import forms
from django.db.models.fields import BLANK_CHOICE_DASH
from django.forms.widgets import flatatt
try:
from django.utils.encoding import force_text
except: # pragma: nocover
from django.utils.encoding import force_unicode as force_text # noqa
from django.utils.encoding import force_text
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
@ -80,7 +77,7 @@ class LinkWidget(forms.Widget):
class RangeWidget(forms.MultiWidget):
def __init__(self, attrs=None):
widgets = (forms.TextInput(attrs=attrs), forms.TextInput(attrs=attrs))
widgets = (forms.TextInput, forms.TextInput)
super(RangeWidget, self).__init__(widgets, attrs)
def decompress(self, value):
@ -97,3 +94,23 @@ class LookupTypeWidget(forms.MultiWidget):
if value is None:
return [None, None]
return value
class BooleanWidget(forms.Widget):
"""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 value_from_datadict(self, data, files, name):
"""
"""
value = super(BooleanWidget, 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

View File

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

View File

@ -12,6 +12,11 @@ Filters
This filter does simple character matches, used with ``CharField`` and
``TextField`` by default.
``UUIDFilter``
~~~~~~~~~~~~~~
This filter matches UUID values, used with ``models.UUIDField`` by default.
``BooleanFilter``
~~~~~~~~~~~~~~~~~
@ -132,6 +137,16 @@ Similar to a ``ChoiceFilter`` except it works with related models, used for
If automatically instantiated ``ModelChoiceFilter`` will use the default ``QuerySet`` for the
related field. If manually instantiated you **must** provide the ``queryset`` kwarg.
Example. ::
class F(FilterSet):
"""Filter for books by author"""
author = ModelChoiceFilter(queryset=Author.objects.all())
class Meta:
model = Book
fields = ['author']
``ModelMultipleChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -217,11 +232,28 @@ of the admin.
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.
filter set class. `action` will default to ``filter_{value-of-name-attribute}``
Example. ::
class F(FilterSet):
"""Filter for Books by if books are published or not"""
published = MethodFilter()
def filter_published(self, queryset, value):
if value:
return queryset.filter(published__isnull=False)
return queryset
class Meta:
model = Book
fields = ['published']
Core Arguments
--------------
These arguments apply to the filters described above.
``name``
~~~~~~~~
@ -242,7 +274,7 @@ 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
mannner similar to the way the Django Admin does, as a series of links.
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``
@ -250,7 +282,8 @@ additional ones that django-filter provides which may be useful:
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.
``Queryset`` that is filtered appropriately. `action` will default to
``filter_{value-of-name-attribute}``
``lookup_type``
~~~~~~~~~~~~~~~
@ -274,9 +307,11 @@ queryset. This option can be used to eliminate duplicate results when using filt
A boolean value that specifies whether the Filter should use ``filter`` or ``exclude`` on the queryset.
Defaults to ``False``.
ModelChoiceFilter and ModelMultipleChoiceFilter
ModelChoiceFilter and ModelMultipleChoiceFilter arguments
--------------
These arguments apply specifically to ModelChoiceFiler and
ModelMultipleChoiceFilter only.
``queryset``
~~~~~~~~~~~

View File

@ -8,7 +8,7 @@ arguments.
~~~~~~~~~~~~~~
This widget renders each option as a link, instead of an actual <input>. It has
one method that you can overide for additional customizability.
one method that you can override for additional customizability.
``option_string()`` should return a string with 3 Python keyword argument
placeholders::
@ -17,3 +17,12 @@ placeholders::
2. ``query_string``: This is the query string for use in the ``href``
option on the ``<a>`` element.
3. ``label``: This is the text to be displayed to the user.
``BooleanWidget``
~~~~~~~~~~~~~~~~~
This widget converts its input into Python's True/False values. It will convert
all case variations of ``True`` and ``False`` into the internal Python values.
To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``::
active = BooleanFilter(widget=BooleanWidget())

View File

@ -268,10 +268,10 @@ 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
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
@ -365,3 +365,33 @@ to it as the class based view::
The needed template and its context variables will also be the same as the
class-based view above.
Custom lookup types
-------------------
By default django-filter uses Django's ORM built-in field lookups. If you want to globally accept specific lookups you can do the following:
from django_filters import filters
filters.LOOKUP_TYPES = ['gt', 'gte', 'lt', 'lte', 'custom_lookup_type']
Choices help text
-----------------
If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following:
from django_filters import filters
filters.LOOKUP_TYPES = [
('', '---------'),
('exact', 'Is equal to'),
('not_exact', 'Is not equal to'),
('lt', 'Lesser than'),
('gt', 'Greater than'),
('gte', 'Greater than or equal to'),
('lte', 'Lesser than or equal to'),
('startswith', 'Starts with'),
('endswith', 'Ends with'),
('contains', 'Contains'),
('not_contains', 'Does not contain'),
]

View File

@ -1 +1,4 @@
bumpversion
twine==1.5.0
wheel==0.24.0
coverage==3.7.1

View File

@ -1,4 +1,4 @@
coverage==3.7.1
django-discover-runner
mock
coverage==3.7.1
sphinx-autobuild

View File

@ -1,29 +1,12 @@
#!/usr/bin/env python
import os
import sys
from django.conf import settings
from django.core.management import call_command
from django.core.management import execute_from_command_line
if not settings.configured:
settings.configure(
DATABASES={
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
},
},
INSTALLED_APPS=(
'django_filters',
'tests',
),
ROOT_URLCONF=None,
USE_TZ=True,
SECRET_KEY='foobar'
)
def runshell():
call_command('syncdb', interactive=False)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
execute_from_command_line(sys.argv[:1] + ['migrate', '--noinput', '-v', '0'])
argv = sys.argv[:1] + ['shell'] + sys.argv[1:]
execute_from_command_line(argv)

View File

@ -1,37 +1,11 @@
#!/usr/bin/env python
import os
import sys
from django import VERSION
from django.conf import settings
from django.core.management import execute_from_command_line
if not settings.configured:
test_runners_args = {}
if VERSION < (1, 6):
test_runners_args = {
'TEST_RUNNER': 'discover_runner.DiscoverRunner',
}
settings.configure(
DATABASES={
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
},
},
INSTALLED_APPS=(
'django.contrib.contenttypes',
'django.contrib.auth',
'django_filters',
'tests',
),
ROOT_URLCONF=None,
USE_TZ=True,
SECRET_KEY='foobar',
SILENCED_SYSTEM_CHECKS=['1_7.W001'],
**test_runners_args
)
def runtests():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
argv = sys.argv[:1] + ['test'] + sys.argv[1:]
execute_from_command_line(argv)

View File

@ -6,11 +6,17 @@ f = open('README.rst')
readme = f.read()
f.close()
version = '0.11.0'
version = '0.12.0'
if sys.argv[-1] == 'publish':
os.system("python setup.py sdist upload")
os.system("python setup.py bdist_wheel upload")
if os.system("pip freeze | grep wheel"):
print("wheel not installed.\nUse `pip install wheel`.\nExiting.")
sys.exit()
if os.system("pip freeze | grep twine"):
print("twine not installed.\nUse `pip install twine`.\nExiting.")
sys.exit()
os.system("python setup.py sdist bdist_wheel")
os.system("twine upload dist/*")
print("You probably want to also tag the version now:")
print(" git tag -a %s -m 'version %s'" % (version, version))
print(" git push --tags")
@ -39,12 +45,12 @@ setup(
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Framework :: Django',
],
include_package_data=True,

View File

@ -31,10 +31,10 @@ class SubnetMaskField(models.Field):
models.Field.__init__(self, *args, **kwargs)
def get_internal_type(self):
return "IPAddressField"
return "GenericIPAddressField"
def formfield(self, **kwargs):
defaults = {'form_class': forms.IPAddressField}
defaults = {'form_class': forms.GenericIPAddressField}
defaults.update(kwargs)
return super(SubnetMaskField, self).formfield(**defaults)
@ -108,7 +108,7 @@ class Restaurant(Place):
class NetworkSetting(models.Model):
ip = models.IPAddressField()
ip = models.GenericIPAddressField()
mask = SubnetMaskField()
@ -179,3 +179,6 @@ class Business(models.Model):
through=HiredWorker,
related_name='employers')
class UUIDTestModel(models.Model):
uuid = models.UUIDField()

29
tests/settings.py Normal file
View File

@ -0,0 +1,29 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
},
}
INSTALLED_APPS = (
'django.contrib.contenttypes',
'django.contrib.auth',
'django_filters',
'tests',
)
ROOT_URLCONF = 'tests.urls'
USE_TZ = True
SECRET_KEY = 'foobar'
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
}]
SILENCED_SYSTEM_CHECKS = [
'1_7.W001', # Unset MIDDLEWARE_CLASSES warning
]

View File

@ -1,23 +1,20 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from datetime import datetime, time
from datetime import datetime, time, timedelta, tzinfo
import decimal
import sys
if sys.version_info >= (2, 7):
import unittest
else: # pragma: nocover
from django.utils import unittest # noqa
import unittest
import django
from django import forms
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils.timezone import make_aware
from django_filters.widgets import RangeWidget
from django_filters.fields import (
RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField)
def to_d(float_value):
return decimal.Decimal('%.2f' % float_value)
@ -120,13 +117,51 @@ class LookupTypeFieldTests(TestCase):
class IsoDateTimeFieldTests(TestCase):
reference_str = "2015-07-19T13:34:51.759"
reference_dt = datetime(2015, 7, 19, 13, 34, 51, 759000)
def test_datetime_string_is_parsed(self):
f = IsoDateTimeField()
d = f.strptime("2015-07-19T13:34:51.759", IsoDateTimeField.ISO_8601)
d = f.strptime(self.reference_str + "", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d, datetime))
def test_datetime_string_with_timezone_is_parsed(self):
f = IsoDateTimeField()
d = f.strptime("2015-07-19T13:34:51.759+01:00", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d, datetime))
d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d, datetime))
def test_datetime_zulu(self):
f = IsoDateTimeField()
d = f.strptime(self.reference_str + "Z", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d, datetime))
def test_datetime_timezone_awareness(self):
# parsed datetimes should obey USE_TZ
f = IsoDateTimeField()
r = make_aware(self.reference_dt, f.default_timezone)
d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d.tzinfo, tzinfo))
self.assertEqual(d, r + r.utcoffset() - d.utcoffset())
d = f.strptime(self.reference_str + "", IsoDateTimeField.ISO_8601)
self.assertTrue(isinstance(d.tzinfo, tzinfo))
self.assertEqual(d, r)
@override_settings(USE_TZ=False)
def test_datetime_timezone_naivety(self):
# parsed datetimes should obey USE_TZ
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))
d = f.strptime(self.reference_str + "", IsoDateTimeField.ISO_8601)
self.assertTrue(d.tzinfo is None)
self.assertEqual(d, r)

View File

@ -3,12 +3,7 @@ from __future__ import unicode_literals
import datetime
import mock
import sys
if sys.version_info >= (2, 7):
import unittest
else: # pragma: nocover
from django.utils import unittest # noqa
import unittest
from django.test import TestCase
from django.utils import six
@ -1331,4 +1326,3 @@ class MiscFilterSetTests(TestCase):
f = F({'status': '2'}, queryset=qs)
self.assertEqual(len(f.qs), 2)
self.assertEqual(f.count(), 2)

View File

@ -1,21 +1,15 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from datetime import date, datetime, time
from datetime import date, time, timedelta
import mock
import sys
if sys.version_info >= (2, 7):
import unittest
else: # pragma: nocover
from django.utils import unittest # noqa
from datetime import timedelta
import unittest
import django
from django import forms
from django.test import TestCase
from django_filters import filters
from django_filters.fields import (
Lookup,
RangeField,
@ -40,6 +34,7 @@ from django_filters.filters import (
DateFromToRangeFilter,
TimeRangeFilter,
AllValuesFilter,
UUIDFilter,
LOOKUP_TYPES)
from tests.models import Book, User
@ -189,7 +184,7 @@ class FilterTests(TestCase):
qs = mock.Mock(spec=['filter', 'distinct'])
f = Filter(name='somefield', distinct=True)
f.filter(qs, 'value')
result = qs.distinct.assert_called_once()
result = qs.distinct.assert_called_once_with()
self.assertNotEqual(qs, result)
@ -201,6 +196,14 @@ class CharFilterTests(TestCase):
self.assertIsInstance(field, forms.CharField)
class UUIDFilterTests(TestCase):
def test_default_field(self):
f = UUIDFilter()
field = f.field
self.assertIsInstance(field, forms.UUIDField)
class BooleanFilterTests(TestCase):
def test_default_field(self):
@ -212,14 +215,14 @@ class BooleanFilterTests(TestCase):
qs = mock.Mock(spec=['filter'])
f = BooleanFilter(name='somefield')
result = f.filter(qs, True)
qs.filter.assert_called_once_with(somefield=True)
qs.filter.assert_called_once_with(somefield__exact=True)
self.assertNotEqual(qs, result)
def test_filtering_exclude(self):
qs = mock.Mock(spec=['exclude'])
f = BooleanFilter(name='somefield', exclude=True)
result = f.filter(qs, True)
qs.exclude.assert_called_once_with(somefield=True)
qs.exclude.assert_called_once_with(somefield__exact=True)
self.assertNotEqual(qs, result)
@unittest.expectedFailure
@ -237,6 +240,13 @@ class BooleanFilterTests(TestCase):
self.assertListEqual(qs.method_calls, [])
self.assertEqual(qs, result)
def test_filtering_lookup_type(self):
qs = mock.Mock(spec=['filter'])
f = BooleanFilter(name='somefield', lookup_type='isnull')
result = f.filter(qs, True)
qs.filter.assert_called_once_with(somefield__isnull=True)
self.assertNotEqual(qs, result)
class ChoiceFilterTests(TestCase):
@ -759,3 +769,30 @@ class AllValuesFilterTests(TestCase):
f.model = mocked
field = f.field
self.assertIsInstance(field, forms.ChoiceField)
class LookupTypesTests(TestCase):
def test_custom_lookup_types(self):
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'),
]
f = Filter(lookup_type=None)
field = f.field
choice_field = field.fields[1]
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')

View File

@ -1,13 +1,9 @@
from __future__ import absolute_import, unicode_literals
import mock
import sys
if sys.version_info >= (2, 7):
import unittest
else: # pragma: nocover
from django.utils import unittest # noqa
import unittest
import django
from django.db import models
from django.test import TestCase
@ -19,6 +15,7 @@ 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 .models import User
from .models import AdminUser
@ -35,6 +32,7 @@ from .models import DirectedNode
from .models import Worker
from .models import HiredWorker
from .models import Business
from .models import UUIDTestModel
def checkItemsEqual(L1, L2):
@ -43,6 +41,7 @@ def checkItemsEqual(L1, L2):
"""
return len(L1) == len(L2) and sorted(L1) == sorted(L2)
class HelperMethodsTests(TestCase):
@unittest.skip('todo')
@ -80,7 +79,7 @@ class DbFieldDefaultFiltersTests(TestCase):
models.FilePathField,
models.FloatField,
models.IntegerField,
models.IPAddressField,
models.GenericIPAddressField,
models.NullBooleanField,
models.PositiveIntegerField,
models.PositiveSmallIntegerField,
@ -92,6 +91,7 @@ class DbFieldDefaultFiltersTests(TestCase):
models.ForeignKey,
models.OneToOneField,
models.ManyToManyField,
models.UUIDField,
]
msg = "%s expected to be found in FILTER_FOR_DBFIELD_DEFAULTS"
@ -102,7 +102,6 @@ class DbFieldDefaultFiltersTests(TestCase):
to_check = [
models.Field,
models.BigIntegerField,
models.GenericIPAddressField,
models.FileField,
models.ImageField,
]
@ -120,6 +119,12 @@ class FilterSetFilterForFieldTests(TestCase):
self.assertIsInstance(result, CharFilter)
self.assertEqual(result.name, 'username')
def test_filter_found_for_uuidfield(self):
f = UUIDTestModel._meta.get_field('uuid')
result = FilterSet.filter_for_field(f, 'uuid')
self.assertIsInstance(result, UUIDFilter)
self.assertEqual(result.name, 'uuid')
def test_filter_found_for_autofield(self):
f = User._meta.get_field('id')
result = FilterSet.filter_for_field(f, 'id')
@ -183,7 +188,7 @@ class FilterSetFilterForFieldTests(TestCase):
class FilterSetFilterForReverseFieldTests(TestCase):
def test_reverse_o2o_relationship(self):
f = Account._meta.get_field_by_name('profile')[0]
f = Account._meta.get_field('profile')
result = FilterSet.filter_for_reverse_field(f, 'profile')
self.assertIsInstance(result, ModelChoiceFilter)
self.assertEqual(result.name, 'profile')
@ -192,7 +197,7 @@ class FilterSetFilterForReverseFieldTests(TestCase):
self.assertEqual(result.extra['queryset'].model, Profile)
def test_reverse_fk_relationship(self):
f = User._meta.get_field_by_name('comments')[0]
f = User._meta.get_field('comments')
result = FilterSet.filter_for_reverse_field(f, 'comments')
self.assertIsInstance(result, ModelMultipleChoiceFilter)
self.assertEqual(result.name, 'comments')
@ -201,7 +206,7 @@ class FilterSetFilterForReverseFieldTests(TestCase):
self.assertEqual(result.extra['queryset'].model, Comment)
def test_reverse_m2m_relationship(self):
f = Book._meta.get_field_by_name('lovers')[0]
f = Book._meta.get_field('lovers')
result = FilterSet.filter_for_reverse_field(f, 'lovers')
self.assertIsInstance(result, ModelMultipleChoiceFilter)
self.assertEqual(result.name, 'lovers')
@ -210,7 +215,7 @@ class FilterSetFilterForReverseFieldTests(TestCase):
self.assertEqual(result.extra['queryset'].model, User)
def test_reverse_non_symmetrical_selfref_m2m_field(self):
f = DirectedNode._meta.get_field_by_name('inbound_nodes')[0]
f = DirectedNode._meta.get_field('inbound_nodes')
result = FilterSet.filter_for_reverse_field(f, 'inbound_nodes')
self.assertIsInstance(result, ModelMultipleChoiceFilter)
self.assertEqual(result.name, 'inbound_nodes')
@ -219,7 +224,7 @@ class FilterSetFilterForReverseFieldTests(TestCase):
self.assertEqual(result.extra['queryset'].model, DirectedNode)
def test_reverse_m2m_field_with_through_model(self):
f = Worker._meta.get_field_by_name('employers')[0]
f = Worker._meta.get_field('employers')
result = FilterSet.filter_for_reverse_field(f, 'employers')
self.assertIsInstance(result, ModelMultipleChoiceFilter)
self.assertEqual(result.name, 'employers')
@ -558,20 +563,30 @@ class FilterSetOrderingTests(TestCase):
self.assertQuerysetEqual(
f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
@unittest.skip('todo')
def test_ordering_uses_filter_name(self):
class F(FilterSet):
account = CharFilter(name='username')
class Meta:
model = User
fields = ['account', 'status']
order_by = True
f = F({'o': 'username'}, queryset=self.qs)
f = F({'o': 'account'}, queryset=self.qs)
self.assertQuerysetEqual(
f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
def test_reverted_ordering_uses_filter_name(self):
class F(FilterSet):
account = CharFilter(name='username')
class Meta:
model = User
fields = ['account', 'status']
order_by = True
f = F({'o': '-account'}, queryset=self.qs)
self.assertQuerysetEqual(
f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
def test_ordering_with_overridden_field_name(self):
"""
Set the `order_by_field` on the queryset and ensure that the
@ -642,14 +657,14 @@ class FilterSetTogetherTests(TestCase):
self.alex = User.objects.create(username='alex', status=1)
self.jacob = User.objects.create(username='jacob', status=2)
self.qs = User.objects.all().order_by('id')
def test_fields_set(self):
class F(FilterSet):
class Meta:
model = User
fields = ['username', 'status', 'is_active', 'first_name']
together = [
('username', 'status'),
('username', 'status'),
('first_name', 'is_active'),
]
@ -660,14 +675,14 @@ class FilterSetTogetherTests(TestCase):
f = F({'username': 'alex', 'status': 1}, queryset=self.qs)
self.assertEqual(f.qs.count(), 1)
self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk)
def test_single_fields_set(self):
class F(FilterSet):
class Meta:
model = User
fields = ['username', 'status']
together = ['username', 'status']
f = F({}, queryset=self.qs)
self.assertEqual(f.qs.count(), 2)
f = F({'username': 'alex'}, queryset=self.qs)

View File

@ -199,7 +199,7 @@ class FilterSetFormTests(TestCase):
self.assertEqual(f.fields['o'].choices,
[('username', 'Account'), ('-username', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')])
def test_ordering_uses_implicit_filter_name(self):
def test_ordering_uses_explicit_filter_name(self):
class F(FilterSet):
account = CharFilter(name='username')
@ -210,7 +210,7 @@ class FilterSetFormTests(TestCase):
f = F().form
self.assertEqual(f.fields['o'].choices,
[('username', 'Account'), ('-username', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')])
[('account', 'Account'), ('-account', 'Account (descending)'), ('status', 'Status'), ('-status', 'Status (descending)')])
def test_ordering_with_overridden_field_name(self):
"""
@ -272,4 +272,3 @@ class FilterSetFormTests(TestCase):
f = F().form
self.assertEqual(
f.fields['o'].choices, [('status', 'Current status')])

View File

@ -2,7 +2,7 @@ from __future__ import absolute_import
from __future__ import unicode_literals
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test import TestCase, override_settings
from django.test.client import RequestFactory
from django_filters.views import FilterView
@ -11,8 +11,8 @@ from django_filters.filterset import FilterSet, filterset_factory
from .models import Book
@override_settings(ROOT_URLCONF='tests.urls')
class GenericViewTestCase(TestCase):
urls = 'tests.urls'
def setUp(self):
Book.objects.create(
@ -78,4 +78,3 @@ class GenericFunctionalViewTests(GenericViewTestCase):
for b in ['Ender&#39;s Game', 'Rainbow Six']:
self.assertNotContains(response, b)
self.assertContains(response, 'Snowcrash')

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.forms import TextInput, Select
from django_filters.widgets import BooleanWidget
from django_filters.widgets import RangeWidget
from django_filters.widgets import LinkWidget
from django_filters.widgets import LookupTypeWidget
@ -135,3 +136,30 @@ class RangeWidgetTests(TestCase):
-
<input type="text" name="price_1" value="9.99" />""")
def test_widget_attributes(self):
w = RangeWidget(attrs={'type': 'date'})
self.assertEqual(len(w.widgets), 2)
self.assertHTMLEqual(w.render('date', ''), """
<input type="date" name="date_0" />
-
<input type="date" name="date_1" />""")
class BooleanWidgetTests(TestCase):
"""
"""
def test_widget_value_from_datadict(self):
"""
"""
w = BooleanWidget()
trueActive = {'active': 'true'}
result = w.value_from_datadict(trueActive, {}, 'active')
self.assertEqual(result, True)
falseActive = {'active': 'false'}
result = w.value_from_datadict(falseActive, {}, 'active')
self.assertEqual(result, False)
result = w.value_from_datadict({}, {}, 'active')
self.assertEqual(result, None)

View File

@ -1,14 +1,13 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from django.conf.urls import patterns
from django.conf.urls import url
from django_filters.views import FilterView
from django_filters.views import FilterView, object_filter
from .models import Book
urlpatterns = patterns('',
(r'^books-legacy/$',
'django_filters.views.object_filter', {'model': Book}),
(r'^books/$', FilterView.as_view(model=Book)),
)
urlpatterns = [
url(r'^books-legacy/$', object_filter, {'model': Book}),
url(r'^books/$', FilterView.as_view(model=Book)),
]

26
tox.ini
View File

@ -1,17 +1,25 @@
[tox]
envlist =
{py26,py27}-django14,
{py26,py27,py32,py33,py34}-django{15,16},
{py27,py32,py33,py34}-django{17,18}
{py27,py32,py33,py34}-django{17,18},
{py27,py34,py35}-django19,
{py27,py34,py35}-django-latest
[testenv]
basepython =
py27: python2.7
py32: python3.2
py33: python3.3
py34: python3.4
py35: python3.5
commands = ./runtests.py
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
django14: Django==1.4.11 # Should track minimum supported
django15: Django==1.5.6 # Should track minimum supported
django16: Django==1.6.3 # Should track minimum supported
django17: Django==1.7.2 # Should track maximum supported
django18: Django==1.8.1 # Should track maximum supported
-rrequirements/test.txt
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/
django-latest: https://github.com/django/django/archive/master.tar.gz
-rrequirements/test.txt