From 8bef80455ab2e6d57fe41691bf1e09fbb4060149 Mon Sep 17 00:00:00 2001 From: Vladimir Prokhoda Date: Sun, 3 Jan 2016 06:57:45 -0800 Subject: [PATCH 01/60] Make Lookup object properly booled --- django_filters/fields.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/django_filters/fields.py b/django_filters/fields.py index 3dee7ae..b4ff32f 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -58,7 +58,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): From 7c97f5240058c78d2b8928d3e20d576f5871f47c Mon Sep 17 00:00:00 2001 From: Vladimir Prokhoda Date: Sat, 9 Jan 2016 11:23:21 -0800 Subject: [PATCH 02/60] TestCase for boolean conversion of fields.Lookup --- tests/test_fields.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index f7809ee..ded6355 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -19,6 +19,20 @@ 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): @@ -46,7 +60,7 @@ class DateRangeFieldTests(TestCase): 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))) From 581dea9d5baaa3d69492edd6985d13db924179cc Mon Sep 17 00:00:00 2001 From: Vladimir Prokhoda Date: Sat, 9 Jan 2016 11:40:04 -0800 Subject: [PATCH 03/60] Tests on simple if check on Lookup in Filter descendants --- tests/test_filters.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index cdcf625..ca598c6 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -165,7 +165,7 @@ class FilterTests(TestCase): 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_type if it provides any qs = mock.Mock(spec=['filter']) f = Filter(name='somefield', lookup_type=None) result = f.filter(qs, Lookup('value', '')) @@ -188,6 +188,33 @@ class FilterTests(TestCase): self.assertNotEqual(qs, result) +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): def test_default_field(self): From 7ca5ed622785f2a07f5855710ae7f1bf0f5ea38d Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Sun, 10 Jan 2016 04:59:42 +0100 Subject: [PATCH 04/60] Fix docs warning --- docs/conf.py | 2 +- docs/ref/fields.txt | 2 +- docs/ref/filters.txt | 4 ++-- docs/ref/widgets.txt | 2 +- docs/usage.txt | 8 ++++++-- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 53e01e1..0cf99d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,7 +91,7 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'classic' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/ref/fields.txt b/docs/ref/fields.txt index 1a3a62d..940724f 100644 --- a/docs/ref/fields.txt +++ b/docs/ref/fields.txt @@ -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 diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 9c6dfe4..3fcc464 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -308,13 +308,13 @@ A boolean value that specifies whether the Filter should use ``filter`` or ``exc 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. diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index 7acd017..17c8de1 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -10,7 +10,7 @@ arguments. This widget renders each option as a link, instead of an actual . 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 ```` tag. diff --git a/docs/usage.txt b/docs/usage.txt index 8b90448..2c260a5 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -298,7 +298,7 @@ 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 +``STRICTNESS.RAISE_VALIDATION_ERROR`` will cause a ``ValidationError`` to be raised if any field contains an invalid value. Overriding ``FilterSet`` methods @@ -380,8 +380,11 @@ 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.py + from django_filters import filters + filters.LOOKUP_TYPES = [ ('', '---------'), ('exact', 'Is equal to'), @@ -395,3 +398,4 @@ If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friend ('contains', 'Contains'), ('not_contains', 'Does not contain'), ] + From 48f34c9b4ff5c5d9be48cac5ebf4f721519a7fd7 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Sun, 10 Jan 2016 05:29:01 +0100 Subject: [PATCH 05/60] Update indexs in docs/conf.py --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0cf99d4..7dfa679 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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'), ] From 43a7e8e65654cc744b3a81adde24c373846812f0 Mon Sep 17 00:00:00 2001 From: edmcdonagh Date: Wed, 27 Jan 2016 22:36:26 +0000 Subject: [PATCH 06/60] Explicitly stated minimum Django version for 0.12.0 --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index f7f3b13..2e73a8a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,8 @@ 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 From 5ff24c5c6b03393a4a12f76579d216172437b3e1 Mon Sep 17 00:00:00 2001 From: kxxoling Date: Sat, 30 Jan 2016 17:51:28 +0800 Subject: [PATCH 07/60] Add Chinese translation --- .../locale/zh_CN/LC_MESSAGES/django.po | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 django_filters/locale/zh_CN/LC_MESSAGES/django.po diff --git a/django_filters/locale/zh_CN/LC_MESSAGES/django.po b/django_filters/locale/zh_CN/LC_MESSAGES/django.po new file mode 100644 index 0000000..de067b9 --- /dev/null +++ b/django_filters/locale/zh_CN/LC_MESSAGES/django.po @@ -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 , 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 \n" +"Language-Team: LANGUAGE \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 "全部" From c3e6760a0869aff6e39c59fd4cb22702f6485be5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Tue, 2 Feb 2016 14:33:45 -0500 Subject: [PATCH 08/60] Fix django 2.0 compatibility warnings --- django_filters/compat.py | 17 +++++++++++++++++ django_filters/filterset.py | 21 +++++++++++---------- tests/models.py | 12 ++++++------ 3 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 django_filters/compat.py diff --git a/django_filters/compat.py b/django_filters/compat.py new file mode 100644 index 0000000..41e76ec --- /dev/null +++ b/django_filters/compat.py @@ -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 diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 686c8be..be519c7 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -16,6 +16,7 @@ from django.utils import six from django.utils.text import capfirst from django.utils.translation import ugettext as _ +from .compat import remote_field, remote_model from .filters import (Filter, CharFilter, BooleanFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) @@ -67,7 +68,7 @@ def get_model_field(model, f): if isinstance(rel, ForeignObjectRel): opts = rel.related_model._meta else: - opts = rel.rel.to._meta + opts = remote_model(rel)._meta try: rel = opts.get_field(parts[-1]) except FieldDoesNotExist: @@ -212,24 +213,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: { @@ -464,7 +465,7 @@ class BaseFilterSet(object): @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, diff --git a/tests/models.py b/tests/models.py index aa2b2cf..37211b0 100644 --- a/tests/models.py +++ b/tests/models.py @@ -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): From 90a9bb170002850cf421a0db3e632c4a531bbed0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 3 Feb 2016 12:05:22 -0500 Subject: [PATCH 09/60] Factor our field mro walking into utility method --- django_filters/filterset.py | 16 +++------------- django_filters/utils.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 django_filters/utils.py diff --git a/django_filters/filterset.py b/django_filters/filterset.py index be519c7..f523b2b 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -20,6 +20,7 @@ from .compat import remote_field, remote_model from .filters import (Filter, CharFilter, BooleanFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) +from .utils import try_dbfield ORDER_BY_FIELD = 'o' @@ -445,19 +446,8 @@ class BaseFilterSet(object): default['choices'] = f.choices return ChoiceFilter(**default) - 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 + data = try_dbfield(filter_for_field.get, f.__class__) or {} + filter_class = data.get('filter_class') default.update(data.get('extra', lambda f: {})(f)) if filter_class is not None: diff --git a/django_filters/utils.py b/django_filters/utils.py new file mode 100644 index 0000000..951beec --- /dev/null +++ b/django_filters/utils.py @@ -0,0 +1,22 @@ + +from django.db import models + + +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 From 3e965a10314e5b17c65ad88584353b05aae399e4 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 3 Feb 2016 12:16:26 -0500 Subject: [PATCH 10/60] Move get_model_field to utils, add documentation --- django_filters/filterset.py | 22 +--------------------- django_filters/utils.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_filterset.py | 9 --------- tests/test_utils.py | 19 +++++++++++++++++++ 4 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 tests/test_utils.py diff --git a/django_filters/filterset.py b/django_filters/filterset.py index f523b2b..1abb0f1 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -10,7 +10,6 @@ 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 @@ -20,7 +19,7 @@ from .compat import remote_field, remote_model from .filters import (Filter, CharFilter, BooleanFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) -from .utils import try_dbfield +from .utils import try_dbfield, get_model_field ORDER_BY_FIELD = 'o' @@ -58,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 = remote_model(rel)._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() diff --git a/django_filters/utils.py b/django_filters/utils.py index 951beec..ebee2ab 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -1,5 +1,10 @@ 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 .compat import remote_model def try_dbfield(fn, field_class): @@ -20,3 +25,33 @@ def try_dbfield(fn, field_class): 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 diff --git a/tests/test_filterset.py b/tests/test_filterset.py index e8a6ff2..c0d3117 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -9,7 +9,6 @@ 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.filters import CharFilter from django_filters.filters import NumberFilter from django_filters.filters import ChoiceFilter @@ -48,14 +47,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 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..66ff250 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,19 @@ + +from django.test import TestCase + +from django_filters.utils import get_model_field + +from .models import User +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')) From ab4013c073f067b38dfe2b9e5aa42da61f4e91c6 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 3 Feb 2016 12:59:43 -0500 Subject: [PATCH 11/60] Add field/lookup expression resolving This will allow us to correctly provide overrides for transformed lookups. eg, `published_at__year__gte=2016` --- django_filters/utils.py | 38 +++++++++++++++ tests/test_utils.py | 100 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/django_filters/utils.py b/django_filters/utils.py index ebee2ab..e9cb373 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -1,6 +1,7 @@ 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 @@ -55,3 +56,40 @@ def get_model_field(model, field_name): 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:] diff --git a/tests/test_utils.py b/tests/test_utils.py index 66ff250..418de9b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,13 @@ 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 +from django_filters.utils import get_model_field, resolve_field from .models import User +from .models import Article from .models import HiredWorker from .models import Business @@ -17,3 +21,97 @@ class GetModelFieldTests(TestCase): 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_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) + + 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') From 2823345ff244f54b3c311785509d67f06d70fab0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 3 Feb 2016 16:33:59 -0500 Subject: [PATCH 12/60] Add lookup expression transform handling --- django_filters/filterset.py | 21 +++++++++++++-------- tests/test_filtering.py | 27 ++++++++++++++++++++++++++- tests/test_filterset.py | 6 ++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 1abb0f1..351ffab 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -19,7 +19,7 @@ from .compat import remote_field, remote_model from .filters import (Filter, CharFilter, BooleanFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) -from .utils import try_dbfield, get_model_field +from .utils import try_dbfield, get_model_field, resolve_field ORDER_BY_FIELD = 'o' @@ -81,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: @@ -412,14 +415,16 @@ class BaseFilterSet(object): return [order_choice] @classmethod - def filter_for_field(cls, f, name, lookup_type='exact'): + def filter_for_field(cls, f, name, lookup_expr='exact'): filter_for_field = dict(FILTER_FOR_DBFIELD_DEFAULTS) filter_for_field.update(cls.filter_overrides) + f, lookup_type = resolve_field(f, lookup_expr) + default = { 'name': name, 'label': capfirst(f.verbose_name), - 'lookup_type': lookup_type + 'lookup_type': lookup_expr } if f.choices: diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 6b09ec6..2f9c0cf 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -5,7 +5,7 @@ import datetime import mock import unittest -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils import six from django.utils.timezone import now from django.utils import timezone @@ -1240,6 +1240,31 @@ class NonSymmetricalSelfReferentialRelationshipTests(TestCase): self.assertQuerysetEqual(f.qs, [2], lambda o: o.pk) +class TransformedQueryExpressionFilterTests(TestCase): + + @override_settings(USE_TZ=False) + def test_filtering(self): + # use naive datetimes, as pytz is required to perform + # date lookups when timezones are involved. + 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) + + class MiscFilterSetTests(TestCase): def setUp(self): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index c0d3117..e7fcdff 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -171,6 +171,12 @@ class FilterSetFilterForFieldTests(TestCase): self.assertIsNotNone(result.extra['queryset']) self.assertEqual(result.extra['queryset'].model, Worker) + 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 From 5aee478f68c3a94c751611d7e8cfc1ebe9f42622 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Feb 2016 00:13:16 -0500 Subject: [PATCH 13/60] Deprecate lookup_type in favor of lookup_expr --- django_filters/filters.py | 39 +++++++++++++++++++++++++++---------- django_filters/filterset.py | 2 +- tests/test_filters.py | 11 +++++++++++ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index d718915..6d16ed0 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -1,9 +1,9 @@ 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 @@ -28,17 +28,26 @@ __all__ = [ 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 +63,26 @@ 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.lookup_expr is None or + isinstance(self.lookup_expr, (list, tuple))): lookup = [] @@ -71,9 +92,7 @@ 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 or x in self.lookup_expr: lookup.append(choice) self._field = LookupTypeField(self.field_class( @@ -90,7 +109,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 +248,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: diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 351ffab..c53d613 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -424,7 +424,7 @@ class BaseFilterSet(object): default = { 'name': name, 'label': capfirst(f.verbose_name), - 'lookup_type': lookup_expr + 'lookup_expr': lookup_expr } if f.choices: diff --git a/tests/test_filters.py b/tests/test_filters.py index ca598c6..b7f3e57 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from datetime import date, time, timedelta import mock +import warnings import unittest import django @@ -187,6 +188,16 @@ 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): From 85a01d6b1449c1d22f102560cdf73821538476fe Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Feb 2016 00:32:17 -0500 Subject: [PATCH 14/60] Update tests and documentation for lookup_expr --- docs/ref/filters.txt | 8 +++--- docs/usage.txt | 25 ++++++++++-------- tests/test_filtering.py | 12 ++++----- tests/test_filters.py | 56 ++++++++++++++++++++--------------------- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 3fcc464..c3689b9 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -171,7 +171,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 @@ -285,16 +285,18 @@ 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`` +``lookup_expr`` ~~~~~~~~~~~~~~~ -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"). +The lookup expression that should be performed using `Django's ORM`_. 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. +.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups + ``distinct`` ~~~~~~~~~~~~ diff --git a/docs/usage.txt b/docs/usage.txt index 2c260a5..d8c45ce 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -40,24 +40,29 @@ declarative syntax:: import django_filters class ProductFilter(django_filters.FilterSet): - price = django_filters.NumberFilter(lookup_type='lt') + price = django_filters.NumberFilter(lookup_expr='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. +Filters take a ``lookup_expr`` argument which specifies what lookup expression to +use with `Django's ORM`_. A detailed explanation of lookup expressions is provided in Django's +`lookup reference`_. django-filter expects an expression to contain only transforms and a final +lookup. ex, ``year__gte``. .. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups +.. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups + +In the above filter, when a user enters a price it would show all Products with a price less than that. + **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:: +specify multiple ``lookup_expr`` filters without significant code duplication:: import django_filters @@ -65,7 +70,7 @@ specify multiple ``lookup_type`` filters without significant code duplication:: class Meta: model = Product fields = {'price': ['lt', 'gt'], - 'release_date': ['exact'], + 'release_date': ['exact', 'year__gt'], } The above would generate 'price__lt', 'price__gt' and 'release_date' filters. @@ -105,7 +110,7 @@ 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', } } } @@ -239,7 +244,7 @@ the user can filter on can also be sorted on. An example or ordering using a lis import django_filters class ProductFilter(django_filters.FilterSet): - price = django_filters.NumberFilter(lookup_type='lt') + price = django_filters.NumberFilter(lookup_expr='lt') class Meta: model = Product fields = ['price', 'release_date'] @@ -380,11 +385,11 @@ Choices help text If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following: -:: +:: # filters.py from django_filters import filters - + filters.LOOKUP_TYPES = [ ('', '---------'), ('exact', 'Is equal to'), diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 2f9c0cf..3aac188 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -446,9 +446,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 +458,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 +468,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 +487,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 diff --git a/tests/test_filters.py b/tests/test_filters.py index b7f3e57..18d6b4b 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -45,7 +45,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): @@ -65,26 +65,26 @@ 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') + 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] @@ -153,22 +153,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) @@ -278,9 +278,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) @@ -541,10 +541,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)) @@ -605,10 +605,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)) @@ -738,10 +738,10 @@ 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))) @@ -782,10 +782,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))) @@ -810,7 +810,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'), @@ -825,7 +825,7 @@ 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 From 77f65875954f33566b1be3dc4f8e699c7cfb3eb5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Feb 2016 02:00:42 -0500 Subject: [PATCH 15/60] Django 1.8 does not support transforms --- docs/usage.txt | 13 ++++++++----- tests/test_filtering.py | 2 ++ tests/test_filterset.py | 1 + tests/test_utils.py | 5 +++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index d8c45ce..2e6b8ac 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -45,16 +45,19 @@ declarative syntax:: model = Product fields = ['price', 'release_date'] -Filters take a ``lookup_expr`` argument which specifies what lookup expression to -use with `Django's ORM`_. A detailed explanation of lookup expressions is provided in Django's -`lookup reference`_. django-filter expects an expression to contain only transforms and a final -lookup. ex, ``year__gte``. +Filters take a ``lookup_expr`` argument which specifies what lookup expression +to use with `Django's ORM`_. A detailed explanation of lookup expressions is +provided in Django's `lookup reference`_. django-filter supports expressions +containing both transforms and a final lookup in version 1.9 of Django and +above. ex, ``year__gte``. For Django version 1.8, transforms expressions are +not supported. .. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups .. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups -In the above filter, when a user enters a price it would show all Products with a price less than that. +In the above filter, when a user enters a price it would show all Products +with a price less than that. **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 diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 3aac188..a6b0b74 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -5,6 +5,7 @@ import datetime import mock import unittest +import django from django.test import TestCase, override_settings from django.utils import six from django.utils.timezone import now @@ -1242,6 +1243,7 @@ class NonSymmetricalSelfReferentialRelationshipTests(TestCase): class TransformedQueryExpressionFilterTests(TestCase): + @unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") @override_settings(USE_TZ=False) def test_filtering(self): # use naive datetimes, as pytz is required to perform diff --git a/tests/test_filterset.py b/tests/test_filterset.py index e7fcdff..087a8d8 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -171,6 +171,7 @@ 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') diff --git a/tests/test_utils.py b/tests/test_utils.py index 418de9b..377191d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,7 @@ +import unittest + +import django from django.test import TestCase from django.db import models from django.db.models.constants import LOOKUP_SEP @@ -40,6 +43,7 @@ class ResolveFieldTests(TestCase): self.assertIsInstance(field, models.CharField) 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. @@ -91,6 +95,7 @@ class ResolveFieldTests(TestCase): 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 From f22cb7491f0785a2e6f395aa1a55e3d3e7cb8aa5 Mon Sep 17 00:00:00 2001 From: William Stewart Date: Mon, 21 Dec 2015 23:37:33 +0200 Subject: [PATCH 16/60] Add CSVWidget --- django_filters/widgets.py | 35 +++++++++++++++++++++++++++++++++++ docs/ref/widgets.txt | 8 ++++++++ tests/test_widgets.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/django_filters/widgets.py b/django_filters/widgets.py index 55ada54..5d9abd7 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -10,8 +10,10 @@ except: from django import forms from django.db.models.fields import BLANK_CHOICE_DASH from django.forms.widgets import flatatt +from django.utils.datastructures import MultiValueDict from django.utils.encoding import force_text from django.utils.safestring import mark_safe +from django.utils import six from django.utils.translation import ugettext as _ @@ -114,3 +116,36 @@ class BooleanWidget(forms.Widget): value = False return value + + +class CommaSeparatedValueWidget(forms.TextInput): + @staticmethod + def sanitize(value_list): + """Remove empty items in case of ?number=1,,2.""" + return [v for v in value_list if v] + + @staticmethod + def customize(value): + """Allow simple type conversion of values.""" + return value + + def value_from_datadict(self, data, files, name): + value = data.get(name) + + if value is None: + return [] + + values = self.sanitize(value.split(',')) + + return [self.customize(value) for value in values] + + def render(self, name, value, attrs=None): + value = value or '' + + final_attrs = self.build_attrs(attrs, type=self.input_type, name=name) + if value: + if isinstance(value, list): + value = ','.join(value) + final_attrs['value'] = force_text(self._format_value(value)) + return super(CommaSeparatedValueWidget, self).render( + name, value, final_attrs) diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index 17c8de1..dff56cd 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -26,3 +26,11 @@ 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()) + +``CommaSeparatedValueWidget`` +~~~~~~~~~~~~~~ + +This widget converts a comma-separated value for an , into a list of +values for an `__in` lookup. It has one method that you can overide for +converting values from the inputted strings. ``customize()`` should return the +value in the same type it's stored in the database. diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 0a74905..63ed0c5 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -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 CommaSeparatedValueWidget from django_filters.widgets import RangeWidget from django_filters.widgets import LinkWidget from django_filters.widgets import LookupTypeWidget @@ -163,3 +164,37 @@ class BooleanWidgetTests(TestCase): result = w.value_from_datadict({}, {}, 'active') self.assertEqual(result, None) + + +class CommaSeparatedValueWidgetTests(TestCase): + def test_widget(self): + w = CommaSeparatedValueWidget() + self.assertHTMLEqual(w.render('price', ''), """ + """) + + self.assertHTMLEqual(w.render('price', '1,2'), """ + """) + + self.assertHTMLEqual(w.render('price', ['1', '2']), """ + """) + + def test_widget_value_from_datadict(self): + w = CommaSeparatedValueWidget() + 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': None} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, []) + + data = {'price': ''} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, []) + + result = w.value_from_datadict({}, {}, 'price') + self.assertEqual(result, []) From 69a89efd59e22c02659e17656ab2f3770961c98c Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 4 Feb 2016 23:16:44 -0500 Subject: [PATCH 17/60] Remove sanitize, differentiate between empty/none - sanitize does not allow for empty values - type conversion should happen at the field level --- django_filters/widgets.py | 40 +++++++++++++-------------------------- docs/ref/widgets.txt | 8 -------- tests/test_widgets.py | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/django_filters/widgets.py b/django_filters/widgets.py index 5d9abd7..c13fc21 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -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 @@ -10,10 +11,9 @@ except: from django import forms from django.db.models.fields import BLANK_CHOICE_DASH from django.forms.widgets import flatatt -from django.utils.datastructures import MultiValueDict from django.utils.encoding import force_text from django.utils.safestring import mark_safe -from django.utils import six +from django.utils.six import string_types from django.utils.translation import ugettext as _ @@ -118,34 +118,20 @@ class BooleanWidget(forms.Widget): return value -class CommaSeparatedValueWidget(forms.TextInput): - @staticmethod - def sanitize(value_list): - """Remove empty items in case of ?number=1,,2.""" - return [v for v in value_list if v] - - @staticmethod - def customize(value): - """Allow simple type conversion of values.""" - return value +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 = data.get(name) + value = super(CSVWidget, self).value_from_datadict(data, files, name) - if value is None: - return [] - - values = self.sanitize(value.split(',')) - - return [self.customize(value) for value in values] + if value is not None: + return value.split(',') + return None def render(self, name, value, attrs=None): - value = value or '' + if self._isiterable(value): + value = [force_text(self._format_value(v)) for v in value] + value = ','.join(list(value)) - final_attrs = self.build_attrs(attrs, type=self.input_type, name=name) - if value: - if isinstance(value, list): - value = ','.join(value) - final_attrs['value'] = force_text(self._format_value(value)) - return super(CommaSeparatedValueWidget, self).render( - name, value, final_attrs) + return super(CSVWidget, self).render(name, value, attrs) diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index dff56cd..17c8de1 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -26,11 +26,3 @@ 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()) - -``CommaSeparatedValueWidget`` -~~~~~~~~~~~~~~ - -This widget converts a comma-separated value for an , into a list of -values for an `__in` lookup. It has one method that you can overide for -converting values from the inputted strings. ``customize()`` should return the -value in the same type it's stored in the database. diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 63ed0c5..125bd23 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -5,7 +5,7 @@ from django.test import TestCase from django.forms import TextInput, Select from django_filters.widgets import BooleanWidget -from django_filters.widgets import CommaSeparatedValueWidget +from django_filters.widgets import CSVWidget from django_filters.widgets import RangeWidget from django_filters.widgets import LinkWidget from django_filters.widgets import LookupTypeWidget @@ -166,9 +166,12 @@ class BooleanWidgetTests(TestCase): self.assertEqual(result, None) -class CommaSeparatedValueWidgetTests(TestCase): +class CSVWidgetTests(TestCase): def test_widget(self): - w = CommaSeparatedValueWidget() + w = CSVWidget() + self.assertHTMLEqual(w.render('price', None), """ + """) + self.assertHTMLEqual(w.render('price', ''), """ """) @@ -178,8 +181,11 @@ class CommaSeparatedValueWidgetTests(TestCase): self.assertHTMLEqual(w.render('price', ['1', '2']), """ """) + self.assertHTMLEqual(w.render('price', [1, 2]), """ + """) + def test_widget_value_from_datadict(self): - w = CommaSeparatedValueWidget() + w = CSVWidget() data = {'price': '1'} result = w.value_from_datadict(data, {}, 'price') self.assertEqual(result, ['1']) @@ -190,11 +196,11 @@ class CommaSeparatedValueWidgetTests(TestCase): data = {'price': None} result = w.value_from_datadict(data, {}, 'price') - self.assertEqual(result, []) + self.assertEqual(result, None) data = {'price': ''} result = w.value_from_datadict(data, {}, 'price') - self.assertEqual(result, []) + self.assertEqual(result, ['']) result = w.value_from_datadict({}, {}, 'price') - self.assertEqual(result, []) + self.assertEqual(result, None) From 0672be5d9299dbe51c8113f12fe71c37048e77c0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 5 Feb 2016 01:05:46 -0500 Subject: [PATCH 18/60] Add base csv, in, and range filters and fields --- django_filters/fields.py | 37 +++++++++++++++++++- django_filters/filters.py | 64 ++++++++++++++++++++++++++++++++- tests/test_fields.py | 47 ++++++++++++++++++++++++- tests/test_filters.py | 74 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/django_filters/fields.py b/django_filters/fields.py index b4ff32f..3afdf2c 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -10,8 +10,9 @@ 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 .widgets import RangeWidget, LookupTypeWidget, CSVWidget class RangeField(forms.MultiValueField): @@ -114,3 +115,37 @@ class IsoDateTimeField(forms.DateTimeField): return timezone.make_naive(parsed, timezone.UTC()) return 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 diff --git a/django_filters/filters.py b/django_filters/filters.py index 6d16ed0..a38f003 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -7,12 +7,15 @@ 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.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) + RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, + IsoDateTimeField, BaseCSVField, BaseRangeField, +) __all__ = [ @@ -337,6 +340,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 + 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 diff --git a/tests/test_fields.py b/tests/test_fields.py index ded6355..9727509 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -12,7 +12,8 @@ from django.utils.timezone import make_aware from django_filters.widgets import RangeWidget from django_filters.fields import ( - RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField) + RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, + IsoDateTimeField, BaseCSVField, BaseRangeField) def to_d(float_value): @@ -179,3 +180,47 @@ 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(['']) + + +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']) diff --git a/tests/test_filters.py b/tests/test_filters.py index 18d6b4b..6e07ceb 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -16,7 +16,8 @@ from django_filters.fields import ( RangeField, DateRangeField, TimeRangeField, - LookupTypeField) + LookupTypeField, + BaseCSVField) from django_filters.filters import ( Filter, CharFilter, @@ -35,6 +36,9 @@ from django_filters.filters import ( DateFromToRangeFilter, TimeRangeFilter, AllValuesFilter, + BaseCSVFilter, + BaseInFilter, + BaseRangeFilter, UUIDFilter, LOOKUP_TYPES) @@ -834,3 +838,71 @@ class LookupTypesTests(TestCase): self.assertEqual(choices, filters.LOOKUP_TYPES) self.assertEqual(choices[1][0], 'exact') self.assertEqual(choices[1][1], 'Is equal to') + + +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]) From 129ce8aa5ffbf6e9d69abbd9e2d2753d0dd5323c Mon Sep 17 00:00:00 2001 From: William Stewart Date: Tue, 22 Dec 2015 11:11:20 +0200 Subject: [PATCH 19/60] Add filter tests Conflicts: django_filters/filters.py --- tests/test_filtering.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index a6b0b74..5fda62e 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -15,6 +15,7 @@ from django_filters.filterset import FilterSet from django_filters.filters import AllValuesFilter from django_filters.filters import CharFilter from django_filters.filters import ChoiceFilter +from django_filters.filters import CSVFilter from django_filters.filters import DateRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import MethodFilter @@ -1353,3 +1354,36 @@ class MiscFilterSetTests(TestCase): f = F({'status': '2'}, queryset=qs) self.assertEqual(len(f.qs), 2) self.assertEqual(f.count(), 2) + + def test_csv_filters(self): + class F(FilterSet): + status = CSVFilter() + + class Meta: + model = User + fields = ['status'] + + qs = User.objects.all() + f = F(queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'status': ''}, queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'status': '0'}, queryset=qs) + self.assertEqual(len(f.qs), 1) + self.assertEqual(f.count(), 1) + + f = F({'status': '0,2'}, queryset=qs) + self.assertEqual(len(f.qs), 3) + self.assertEqual(f.count(), 3) + + f = F({'status': '0,,1'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'status': '2'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) From 1462a3f77b3452c554593b7724e8e4eb28fa2110 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 5 Feb 2016 02:01:12 -0500 Subject: [PATCH 20/60] Update filtering tests --- tests/test_filtering.py | 12 +++++++----- tests/test_widgets.py | 9 +++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 5fda62e..c82d2e5 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -13,9 +13,9 @@ 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 CSVFilter from django_filters.filters import DateRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import MethodFilter @@ -1356,12 +1356,14 @@ class MiscFilterSetTests(TestCase): self.assertEqual(f.count(), 2) def test_csv_filters(self): + class NumberInFilter(BaseInFilter, NumberFilter): + pass + class F(FilterSet): - status = CSVFilter() + status = NumberInFilter() class Meta: model = User - fields = ['status'] qs = User.objects.all() f = F(queryset=qs) @@ -1369,8 +1371,8 @@ class MiscFilterSetTests(TestCase): self.assertEqual(f.count(), 4) f = F({'status': ''}, queryset=qs) - self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) + self.assertEqual(len(f.qs), 0) + self.assertEqual(f.count(), 0) f = F({'status': '0'}, queryset=qs) self.assertEqual(len(f.qs), 1) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 125bd23..b92d9ba 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -186,6 +186,11 @@ class CSVWidgetTests(TestCase): 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']) @@ -194,9 +199,9 @@ class CSVWidgetTests(TestCase): result = w.value_from_datadict(data, {}, 'price') self.assertEqual(result, ['1', '2']) - data = {'price': None} + data = {'price': '1,,2'} result = w.value_from_datadict(data, {}, 'price') - self.assertEqual(result, None) + self.assertEqual(result, ['1', '', '2']) data = {'price': ''} result = w.value_from_datadict(data, {}, 'price') From cd16637bf60a4fb8fe1b08d7fe15fee13d2d73a5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 5 Feb 2016 02:17:50 -0500 Subject: [PATCH 21/60] Add filters for specific lookup_types - Includes 'isnull' handling - Includes 'in' and 'range' handling that are dynamically created for its underlying field type. --- django_filters/filterset.py | 80 ++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index c53d613..0a4df6b 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -16,7 +16,7 @@ from django.utils.text import capfirst from django.utils.translation import ugettext as _ from .compat import remote_field, remote_model -from .filters import (Filter, CharFilter, BooleanFilter, +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 @@ -416,9 +416,6 @@ class BaseFilterSet(object): @classmethod def filter_for_field(cls, f, name, lookup_expr='exact'): - filter_for_field = dict(FILTER_FOR_DBFIELD_DEFAULTS) - filter_for_field.update(cls.filter_overrides) - f, lookup_type = resolve_field(f, lookup_expr) default = { @@ -427,14 +424,9 @@ class BaseFilterSet(object): '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 = try_dbfield(filter_for_field.get, f.__class__) or {} - - filter_class = data.get('filter_class') - default.update(data.get('extra', lambda f: {})(f)) if filter_class is not None: return filter_class(**default) @@ -452,6 +444,72 @@ 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': + return BooleanFilter, {} + + if lookup_type == 'in': + class ConcreteInFilter(BaseInFilter, filter_class): + pass + ConcreteInFilter.__name__ = cls._csv_filter_class_name( + filter_class, lookup_type + ) + + return ConcreteInFilter, {} + + if lookup_type == 'range': + class ConcreteRangeFilter(BaseRangeFilter, filter_class): + pass + ConcreteRangeFilter.__name__ = cls._csv_filter_class_name( + filter_class, lookup_type + ) + + return ConcreteRangeFilter, {} + + # 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 + 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 From 9d9aeb309ba6a488454a03f308765d4bbeee2e8d Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Fri, 5 Feb 2016 17:45:38 +0100 Subject: [PATCH 22/60] Update Django 1.9 version identifier --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 15d9045..59f6f96 100644 --- a/tox.ini +++ b/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 From 7d499155658f4aa43f360295275211fa82394786 Mon Sep 17 00:00:00 2001 From: newfylox Date: Fri, 5 Feb 2016 14:11:52 -0500 Subject: [PATCH 23/60] fix the way lookup_type choices are rendered if custom help text is given --- django_filters/filters.py | 9 +++++++-- tests/test_filters.py | 25 +++++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index d718915..7192150 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -73,8 +73,13 @@ class Filter(object): if self.lookup_type is None: lookup.append(choice) - elif x in self.lookup_type: - lookup.append(choice) + else: + if isinstance(x, (list, tuple)) and len(x) == 2: + if x[0] in self.lookup_type: + lookup.append(choice) + else: + if x in self.lookup_type: + lookup.append(choice) self._field = LookupTypeField(self.field_class( required=self.required, widget=self.widget, **self.extra), diff --git a/tests/test_filters.py b/tests/test_filters.py index ca598c6..0b46928 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -817,9 +817,26 @@ class LookupTypesTests(TestCase): f = Filter(lookup_type=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_type=('endswith', 'not_contains')) + custom_field = custom_f.field + custom_choice_field = custom_field.fields[1] + my_custom_choices = custom_choice_field.choices + + available_lookup_types = [ + ('endswith', 'Ends with'), + ('not_contains', 'Does not contain'), + ] + + self.assertIsInstance(custom_field, LookupTypeField) + self.assertEqual(my_custom_choices, available_lookup_types) + 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') From 81dd1ecd8e9c795a7d9e12cb66bf281e43803dea Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Wed, 17 Feb 2016 15:47:13 +0000 Subject: [PATCH 24/60] Fixed code formatting in usage docs --- docs/usage.txt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index 2c260a5..b2f2e2c 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -369,7 +369,7 @@ 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: +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 @@ -378,9 +378,7 @@ By default django-filter uses Django's ORM built-in field lookups. If you want t Choices help text ----------------- -If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following: - -:: +If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following:: # filters.py from django_filters import filters From d1e71007c88d9b4e1f9d8a5cd24c49d18371f5cb Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Wed, 17 Feb 2016 15:33:20 +0000 Subject: [PATCH 25/60] Update docs examples to use render instead of render_to_response --- README.rst | 2 +- docs/usage.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index bd10d50..ff7611e 100644 --- a/README.rst +++ b/README.rst @@ -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:: diff --git a/docs/usage.txt b/docs/usage.txt index 2c260a5..ad2e98c 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -188,7 +188,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. From 55623ea9a54913245273cebd74a3e1524911862d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 17 Feb 2016 20:34:28 +0100 Subject: [PATCH 26/60] test_filterset: remove unused import of django --- tests/test_filterset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index e8a6ff2..286917b 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -3,7 +3,6 @@ from __future__ import absolute_import, unicode_literals import mock import unittest -import django from django.db import models from django.test import TestCase From a92bebc8b6abd7c714dd325b1e28683192d80b07 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 17 Feb 2016 20:35:52 +0100 Subject: [PATCH 27/60] Do not apply default order_by with STRICTNESS.RAISE_VALIDATION_ERROR Fixes https://github.com/alex/django-filter/issues/373. --- django_filters/filterset.py | 4 +++- tests/test_filterset.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index be519c7..85a0feb 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -356,7 +356,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: diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 286917b..f0cd458 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -3,11 +3,13 @@ from __future__ import absolute_import, unicode_literals import mock import unittest +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 STRICTNESS from django_filters.filterset import get_model_field from django_filters.filters import CharFilter from django_filters.filters import NumberFilter @@ -536,7 +538,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 @@ -544,6 +560,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) From 634c65260027cd446b1f2de99e0e0f87876a151e Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 18 Feb 2016 14:32:58 -0500 Subject: [PATCH 28/60] Add more thorough tests for CSV filters --- django_filters/filterset.py | 4 +- tests/test_filtering.py | 200 +++++++++++++++++++++++++++++------- tests/test_utils.py | 43 ++++++++ 3 files changed, 206 insertions(+), 41 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 0a4df6b..67c8620 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -468,7 +468,7 @@ class BaseFilterSet(object): filter_class, lookup_type ) - return ConcreteInFilter, {} + return ConcreteInFilter, params if lookup_type == 'range': class ConcreteRangeFilter(BaseRangeFilter, filter_class): @@ -477,7 +477,7 @@ class BaseFilterSet(object): filter_class, lookup_type ) - return ConcreteRangeFilter, {} + return ConcreteRangeFilter, params # Default behavior if f.choices: diff --git a/tests/test_filtering.py b/tests/test_filtering.py index c82d2e5..29754ad 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -1242,13 +1242,13 @@ 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): - @unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") - @override_settings(USE_TZ=False) def test_filtering(self): - # use naive datetimes, as pytz is required to perform - # date lookups when timezones are involved. now_dt = datetime.datetime.now() after_5pm = now_dt.replace(hour=18) before_5pm = now_dt.replace(hour=16) @@ -1268,6 +1268,163 @@ class TransformedQueryExpressionFilterTests(TestCase): 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): @@ -1354,38 +1511,3 @@ class MiscFilterSetTests(TestCase): f = F({'status': '2'}, queryset=qs) self.assertEqual(len(f.qs), 2) self.assertEqual(f.count(), 2) - - def test_csv_filters(self): - class NumberInFilter(BaseInFilter, NumberFilter): - pass - - class F(FilterSet): - status = NumberInFilter() - - class Meta: - model = User - - qs = User.objects.all() - f = F(queryset=qs) - self.assertEqual(len(f.qs), 4) - self.assertEqual(f.count(), 4) - - f = F({'status': ''}, queryset=qs) - self.assertEqual(len(f.qs), 0) - self.assertEqual(f.count(), 0) - - f = F({'status': '0'}, queryset=qs) - self.assertEqual(len(f.qs), 1) - self.assertEqual(f.count(), 1) - - f = F({'status': '0,2'}, queryset=qs) - self.assertEqual(len(f.qs), 3) - self.assertEqual(f.count(), 3) - - f = F({'status': '0,,1'}, queryset=qs) - self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) - - f = F({'status': '2'}, queryset=qs) - self.assertEqual(len(f.qs), 2) - self.assertEqual(f.count(), 2) diff --git a/tests/test_utils.py b/tests/test_utils.py index 377191d..db5b790 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,6 +11,7 @@ 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 @@ -43,6 +44,48 @@ class ResolveFieldTests(TestCase): 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) + + 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): """ From f4178fb9d7a96fc1bf0883e70b9f4225c3f35d08 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 18 Feb 2016 15:13:12 -0500 Subject: [PATCH 29/60] Django 1.8 does not support reverse rel lookups --- tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index db5b790..90285c5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -65,6 +65,7 @@ class ResolveFieldTests(TestCase): 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 From f876d269735c7803f51e6296f66ebd17d7a21c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Thu, 18 Feb 2016 21:28:49 +0100 Subject: [PATCH 30/60] Add DateTimeRangeField and DateTimeFromToRangeFilter --- django_filters/fields.py | 9 ++++ django_filters/filters.py | 11 ++-- tests/test_fields.py | 21 +++++++- tests/test_filters.py | 105 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/django_filters/fields.py b/django_filters/fields.py index b4ff32f..fc435e3 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -49,6 +49,15 @@ class DateRangeField(RangeField): 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): diff --git a/django_filters/filters.py b/django_filters/filters.py index 7192150..6caf4b8 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -12,7 +12,8 @@ from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from .fields import ( - RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField) + RangeField, LookupTypeField, Lookup, DateRangeField, DateTimeRangeField, + TimeRangeField, IsoDateTimeField) __all__ = [ @@ -20,8 +21,8 @@ __all__ = [ 'TypedChoiceFilter', 'MultipleChoiceFilter', 'DateFilter', 'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter', 'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter', - 'DateRangeFilter', 'DateFromToRangeFilter', 'TimeRangeFilter', - 'AllValuesFilter', 'MethodFilter' + 'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter', + 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter' ] @@ -310,6 +311,10 @@ class DateFromToRangeFilter(RangeFilter): field_class = DateRangeField +class DateTimeFromToRangeFilter(RangeFilter): + field_class = DateTimeRangeField + + class TimeRangeFilter(RangeFilter): field_class = TimeRangeField diff --git a/tests/test_fields.py b/tests/test_fields.py index ded6355..402b157 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -12,7 +12,8 @@ from django.utils.timezone import make_aware from django_filters.widgets import RangeWidget from django_filters.fields import ( - RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField) + RangeField, LookupTypeField, Lookup, DateRangeField, DateTimeRangeField, + TimeRangeField, IsoDateTimeField) def to_d(float_value): @@ -54,16 +55,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), 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): diff --git a/tests/test_filters.py b/tests/test_filters.py index 0b46928..a0fd8aa 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,19 +1,20 @@ 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 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 import filters, FilterSet from django_filters.fields import ( Lookup, RangeField, DateRangeField, + DateTimeRangeField, TimeRangeField, LookupTypeField) from django_filters.filters import ( @@ -32,12 +33,13 @@ from django_filters.filters import ( RangeFilter, DateRangeFilter, DateFromToRangeFilter, + DateTimeFromToRangeFilter, TimeRangeFilter, AllValuesFilter, UUIDFilter, LOOKUP_TYPES) -from tests.models import Book, User +from tests.models import Book, User, Article, Comment class FilterTests(TestCase): @@ -735,6 +737,101 @@ class DateFromToRangeFilterTests(TestCase): qs.filter.assert_called_once_with( None__range=(date(2015, 4, 7), date(2015, 9, 6))) + def test_filtering_queryset(self): + class F(FilterSet): + published = DateFromToRangeFilter(name='date') + class Meta: + model = Comment + fields = ['date'] + adam = User.objects.create(username='adam') + kwargs = {'text': 'test', 'author': adam, 'time': '10:00'} + Comment.objects.create(date=date(2016, 1, 1), **kwargs) + Comment.objects.create(date=date(2016, 1, 2), **kwargs) + Comment.objects.create(date=date(2016, 1, 3), **kwargs) + Comment.objects.create(date=date(2016, 1, 3), **kwargs) + results = F(data={ + 'published_0': '2016-01-02', + 'published_1': '2016-01-03'}) + self.assertEqual(len(results.qs), 3) + + @override_settings(USE_TZ=False) + def test_filtering_ignores_time(self): + class F(FilterSet): + published = DateFromToRangeFilter() + class Meta: + model = Article + fields = ['published'] + Article.objects.create(published=datetime(2016, 1, 1, 10, 0)) + Article.objects.create(published=datetime(2016, 1, 2, 12, 45)) + Article.objects.create(published=datetime(2016, 1, 3, 18, 15)) + Article.objects.create(published=datetime(2016, 1, 3, 19, 30)) + results = F(data={ + 'published_0': '2016-01-02', + 'published_1': '2016-01-03'}) + self.assertEqual(len(results.qs), 3) + + +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))) + + @override_settings(USE_TZ=False) + def test_filtering_queryset(self): + class F(FilterSet): + published = DateTimeFromToRangeFilter() + class Meta: + model = Article + fields = ['published'] + Article.objects.create(published=datetime(2016, 1, 1, 10, 0)) + Article.objects.create(published=datetime(2016, 1, 2, 12, 45)) + Article.objects.create(published=datetime(2016, 1, 3, 18, 15)) + Article.objects.create(published=datetime(2016, 1, 3, 19, 30)) + 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): From 1136e53be95494d1690abd58c36ec1f99f67142d Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 18 Feb 2016 15:34:56 -0500 Subject: [PATCH 31/60] Add tests for filter_for_lookup --- tests/test_filterset.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 37ab125..3b2e078 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -11,15 +11,19 @@ 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 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 .models import User from .models import AdminUser +from .models import Article from .models import Book from .models import Profile from .models import Comment @@ -185,6 +189,29 @@ class FilterSetFilterForFieldTests(TestCase): 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') + + class FilterSetFilterForReverseFieldTests(TestCase): def test_reverse_o2o_relationship(self): From 93e9a128df4382cad3f85b44464d7e7249093245 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 18 Feb 2016 15:49:05 -0500 Subject: [PATCH 32/60] Added docs for base CSV filters --- docs/ref/filters.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index c3689b9..026702c 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -227,6 +227,34 @@ 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. +``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') + +``BaseRangeFilter`` +~~~~~~~~~~~~~~~~~~~ + +This is a base class used for generating RANGE lookup filters. Values are expected to be comma separated. + +Example. :: + + class NumberRangeFilter(BaseInFilter, NumericFilter): + pass + + class F(FilterSet): + id__range = NumberRangeFilter(name=id, lookup_expr='range') + + + ``MethodFilter`` ~~~~~~~~~~~~~~~~~~~ From a97f7a5d356e55661a862756b27f1be47f333f93 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 18 Feb 2016 16:00:59 -0500 Subject: [PATCH 33/60] Fix typo --- docs/usage.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.txt b/docs/usage.txt index f23192b..c077e1a 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -49,7 +49,7 @@ Filters take a ``lookup_expr`` argument which specifies what lookup expression to use with `Django's ORM`_. A detailed explanation of lookup expressions is provided in Django's `lookup reference`_. django-filter supports expressions containing both transforms and a final lookup in version 1.9 of Django and -above. ex, ``year__gte``. For Django version 1.8, transforms expressions are +above. ex, ``year__gte``. For Django version 1.8, transformed expressions are not supported. .. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups From 62d77b19ecbacc9bb8fb7c77c7c96e2fb590a2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Thu, 18 Feb 2016 22:38:33 +0100 Subject: [PATCH 34/60] Update docs --- docs/ref/filters.txt | 106 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 3fcc464..4151c9b 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -211,14 +211,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`` ~~~~~~~~~~~~~~~~~~~ From 410ebc0bba24bc6628a0aeac5665d86fe1bc7bab Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 11:40:39 -0500 Subject: [PATCH 35/60] Add type checking to CSV validation test --- tests/test_fields.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_fields.py b/tests/test_fields.py index 8eb1510..24a4b7d 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -217,6 +217,9 @@ class BaseCSVFieldTests(TestCase): 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): From b7f82a2804f76a54b67ee2d9d91e7abcb2eaee98 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 11:41:39 -0500 Subject: [PATCH 36/60] Add more usage details to CSV filters --- docs/ref/filters.txt | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 74809a6..fb664ed 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -264,7 +264,7 @@ Example of using the ``DateTimeField`` field:: 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``. @@ -344,10 +344,22 @@ Example. :: 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. Values are expected to be comma separated. +This is a base class used for generating RANGE lookup filters. The base class expects two values two, separated by a comma. Example. :: @@ -357,10 +369,21 @@ Example. :: 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 From b8f25d97542a9ababecadf8a1d8440077fdd9c34 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 12:24:29 -0500 Subject: [PATCH 37/60] Add declarative filter docs --- docs/ref/filters.txt | 3 ++ docs/usage.txt | 75 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index fb664ed..6e3c283 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -404,6 +404,9 @@ Example. :: model = Book fields = ['published'] + +.. _core-arguments: + Core Arguments -------------- diff --git a/docs/usage.txt b/docs/usage.txt index c077e1a..a452eb6 100644 --- a/docs/usage.txt +++ b/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,34 +37,73 @@ 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. - import django_filters +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 ` on a ``FilterSet``:: class ProductFilter(django_filters.FilterSet): - price = django_filters.NumberFilter(lookup_expr='lt') + 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 - fields = ['price', 'release_date'] -Filters take a ``lookup_expr`` argument which specifies what lookup expression -to use with `Django's ORM`_. A detailed explanation of lookup expressions is -provided in Django's `lookup reference`_. django-filter supports expressions -containing both transforms and a final lookup in version 1.9 of Django and -above. ex, ``year__gte``. For Django version 1.8, transformed expressions are -not supported. +There are two main arguments for filters: -.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups +- ``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 -In the above filter, when a user enters a price it would show all Products -with a price less than that. +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:: -**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.** + 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 fields can additionally be set using a Dictionary to specify multiple ``lookup_expr`` filters without significant code duplication:: From 366dff1cf0cab40b9ea9cc73d6a2879d4a7a664e Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 12:45:21 -0500 Subject: [PATCH 38/60] Restructure dict-syntax and overrides docs --- docs/usage.txt | 86 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index a452eb6..3ad9eec 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -105,21 +105,41 @@ For more information on additional arguments, see :ref:`core-arguments`. Generating filters with Meta.fields ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The FilterSet Meta class fields can additionally be set using a Dictionary to -specify multiple ``lookup_expr`` filters without significant code duplication:: +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): class Meta: model = Product - fields = {'price': ['lt', 'gt'], - 'release_date': ['exact', 'year__gt'], - } + fields = ['price', 'release_date'] -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 generates 'exact' lookups for both the 'price' and 'release_date' +fields. + +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', 'year__gt'], + } + +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 @@ -130,6 +150,38 @@ related model:: model = Product fields = ['manufacturer__country'] + +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 +``filter_overrides``:: + + class ProductFilter(django_filters.FilterSet): + filter_overrides = { + models.CharField: { + 'filter_class': django_filters.CharFilter, + 'extra': lambda f: { + 'lookup_expr': 'icontains', + }, + }, + models.BooleanField: { + 'filter_class': django_filters.BooleanFilter, + 'extra': lambda f: { + 'widget': 'forms.CheckboxInput', + }, + }, + } + + class Meta: + model = Product + fields = { + 'name': ['exact'], + 'release_date': ['isnull'], + } + + 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 @@ -145,24 +197,6 @@ in ``Filter.extra``, so it's possible to override the initializer of a self.filters['manufacturer'].extra.update( {'empty_label': 'All Manufacturers'}) -Like ``django.contrib.admin.ModelAdmin``, it is possible to override -default filters for all the models fields of the same kind using -``filter_overrides``:: - - class ProductFilter(django_filters.FilterSet): - filter_overrides = { - models.CharField: { - 'filter_class': django_filters.CharFilter, - 'extra': lambda f: { - 'lookup_expr': 'icontains', - } - } - } - - class Meta: - model = Product - fields = ['name'] - MethodFilter ~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 4ea3b0a084596973c5dcb8587e5f076e69166aed Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 13:03:36 -0500 Subject: [PATCH 39/60] Remove section on __init__ override This doesn't really seem appropriate for an introductory section on basic FilterSet usage. --- docs/usage.txt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index 3ad9eec..f700b1c 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -182,22 +182,6 @@ default filters for all the models fields of the same kind using } -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'}) - - MethodFilter ~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you want fine control over each individual filter attribute, you can use From 4b0aec7cd85f1ab80ff1c141afca7e8b1552211f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 13:05:16 -0500 Subject: [PATCH 40/60] Update MethodFilter heading --- docs/usage.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/usage.txt b/docs/usage.txt index f700b1c..7707830 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -182,8 +182,9 @@ default filters for all the models fields of the same kind using } -MethodFilter -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Custom filtering with MethodFilter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + If you want fine control over each individual filter attribute, you can use the ``MethodFilter`` filter. From b50992f5023d9121598ab493c2836555ab784618 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 13:13:20 -0500 Subject: [PATCH 41/60] Move addtl. FilterSet details to separate section --- docs/index.txt | 1 + docs/ref/filterset.txt | 140 ++++++++++++++++++++++++++++++++++++++++ docs/usage.txt | 142 ++--------------------------------------- 3 files changed, 145 insertions(+), 138 deletions(-) create mode 100644 docs/ref/filterset.txt diff --git a/docs/index.txt b/docs/index.txt index ba29257..61626bc 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -14,6 +14,7 @@ Contents: install usage + ref/filterset ref/filters ref/fields ref/widgets diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt new file mode 100644 index 0000000..1f08c3e --- /dev/null +++ b/docs/ref/filterset.txt @@ -0,0 +1,140 @@ +FilterSet Guide +=============== + +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_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. + +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) + + +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:: + + # 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'), + ] + diff --git a/docs/usage.txt b/docs/usage.txt index 7707830..10609fc 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -264,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 ------------ @@ -271,6 +272,7 @@ We need a URL pattern to call the view:: url(r'^list$', views.product_list) + The template ------------ @@ -292,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_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. - -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``. @@ -433,35 +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:: - - # 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'), - ] - From e71f138aab610f6743cdb0acdd4eb282ba3820fd Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 13:45:25 -0500 Subject: [PATCH 42/60] Move arguments to top of reference --- docs/ref/filters.txt | 162 +++++++++++++++++++++---------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 6e3c283..d2c52ef 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -3,6 +3,87 @@ 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``. + +``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_expr`` +~~~~~~~~~~~~~~~ + +The lookup expression that should be performed using `Django's ORM`_. +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. + +.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups + +``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 ------- @@ -403,84 +484,3 @@ Example. :: class Meta: model = Book fields = ['published'] - - -.. _core-arguments: - -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_expr`` -~~~~~~~~~~~~~~~ - -The lookup expression that should be performed using `Django's ORM`_. -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. - -.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups - -``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``. From 9fe1f77fd55979eb2ea7b9c1b79e9ffc30caebc0 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 17:11:43 -0500 Subject: [PATCH 43/60] Move LOOKUP_TYPES docs to filters ref & rewrite --- docs/ref/filters.txt | 39 ++++++++++++++++++++++++++++++++++----- docs/ref/filterset.txt | 33 --------------------------------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index d2c52ef..18de493 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -45,14 +45,43 @@ recieves a ``QuerySet`` and the value to filter on and should return a ~~~~~~~~~~~~~~~ The lookup expression that should be performed using `Django's ORM`_. -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. .. _`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 no lookups are 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`` ~~~~~~~~~~~~ diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index 1f08c3e..be9504f 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -105,36 +105,3 @@ first name:: if order_value == 'last_name': return ['last_name', 'first_name'] return super(UserFilter, self).get_order_by(order_value) - - -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:: - - # 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'), - ] - From 059e15d13a6ac3ec91db5b5a89e7083efca5ab73 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 17:44:20 -0500 Subject: [PATCH 44/60] Add relationship traversal note to filer args --- docs/ref/filters.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 18de493..71dc8dc 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -15,6 +15,8 @@ The following are the core arguments that apply to all filters. 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`` ~~~~~~~~~ From d61c78963a9347696ee6c14def4ceb183250cbbb Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 17:44:41 -0500 Subject: [PATCH 45/60] Add structuring to the FilterSet guide --- docs/ref/filterset.txt | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index be9504f..f826abb 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -1,8 +1,20 @@ FilterSet Guide =============== -Other Meta options ------------------- +This document provides a guide on using additional FilterSet features. + +Meta options +------------ + +- model +- fields +- exclude +- :ref:`order_by ` +- :ref:`form
` +- :ref:`together ` + + +.. _order-by: Ordering using ``order_by`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -35,6 +47,9 @@ 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`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -42,6 +57,9 @@ 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`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -66,6 +84,13 @@ 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`` ~~~~~~~~~~ From 43db6963ab8b735787b0aa867c7effa69b582399 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 19 Feb 2016 19:56:31 -0500 Subject: [PATCH 46/60] Add override details for filter_for_lookup() --- docs/ref/filterset.txt | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index f826abb..a059de9 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -106,6 +106,38 @@ 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()`` ~~~~~~~~~~~~~~~~~~~~~~~~ From ae59d11979ed245183f9daf0fcdaffa2345be8c1 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 20 Feb 2016 16:29:07 -0500 Subject: [PATCH 47/60] Minor docs additions & fixes --- docs/ref/filters.txt | 14 ++++++++++---- docs/ref/filterset.txt | 2 +- docs/ref/widgets.txt | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 71dc8dc..4cd2186 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -31,9 +31,15 @@ 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"``. + * :ref:`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"``. + * :ref:`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. + * :ref:`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. ``action`` ~~~~~~~~~~ @@ -52,7 +58,7 @@ The lookup expression that should be performed using `Django's ORM`_. 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 no lookups are passed, then a list of all lookup +``filters.LOOKUP_TYPES``. If `lookup_expr=None` is passed, then a list of all lookup types will be generated:: class ProductFilter(django_filters.FilterSet): diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index a059de9..0967bf4 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -23,7 +23,7 @@ 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:: +the user can filter on can also be sorted on. An example of ordering using a list:: import django_filters diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index 17c8de1..4aabf1c 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -4,6 +4,9 @@ Widget Reference This is a reference document with a list of the provided widgets and their arguments. + +.. _link-widget: + ``LinkWidget`` ~~~~~~~~~~~~~~ @@ -18,6 +21,9 @@ placeholders: option on the ```` 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. From 5f52896c1fd57743ac30a1200b13de026034a708 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 20 Feb 2016 17:16:20 -0500 Subject: [PATCH 48/60] Make 'isnull' conform to filter_overrides --- django_filters/filterset.py | 6 +++++- tests/test_filterset.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index bc2105e..8683979 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -461,7 +461,11 @@ class BaseFilterSet(object): # perform lookup specific checks if lookup_type == 'isnull': - return BooleanFilter, {} + 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): diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 3b2e078..640fe1a 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -21,6 +21,8 @@ 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 @@ -211,6 +213,22 @@ class FilterSetFilterForLookupTests(TestCase): 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): From 35bc0d417e0199dfbe8273259313d8cac38ba734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Wed, 24 Feb 2016 17:26:03 +0100 Subject: [PATCH 49/60] Clean up tests and get rid of RuntimeWarning --- django_filters/fields.py | 5 ++ tests/test_filtering.py | 99 ++++++++++++++++++++++++++++++++++++++++ tests/test_filters.py | 49 -------------------- 3 files changed, 104 insertions(+), 49 deletions(-) diff --git a/django_filters/fields.py b/django_filters/fields.py index 9b08ef1..ef74238 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -10,6 +10,7 @@ from django.utils.dateparse import parse_datetime from django.utils import timezone from django.utils.encoding import force_str +from django.utils.timezone import make_aware from django.utils.translation import ugettext_lazy as _ from .widgets import RangeWidget, LookupTypeWidget, CSVWidget @@ -44,8 +45,12 @@ class DateRangeField(RangeField): start_date, stop_date = data_list if start_date: start_date = datetime.combine(start_date, time.min) + if settings.USE_TZ: + start_date = make_aware(start_date) if stop_date: stop_date = datetime.combine(stop_date, time.max) + if settings.USE_TZ: + stop_date = make_aware(stop_date) return slice(start_date, stop_date) return None diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 29754ad..79c026e 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -17,12 +17,15 @@ 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 @@ -621,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): diff --git a/tests/test_filters.py b/tests/test_filters.py index bf45c2d..8aca9cb 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -752,39 +752,6 @@ class DateFromToRangeFilterTests(TestCase): qs.filter.assert_called_once_with( None__range=(date(2015, 4, 7), date(2015, 9, 6))) - def test_filtering_queryset(self): - class F(FilterSet): - published = DateFromToRangeFilter(name='date') - class Meta: - model = Comment - fields = ['date'] - adam = User.objects.create(username='adam') - kwargs = {'text': 'test', 'author': adam, 'time': '10:00'} - Comment.objects.create(date=date(2016, 1, 1), **kwargs) - Comment.objects.create(date=date(2016, 1, 2), **kwargs) - Comment.objects.create(date=date(2016, 1, 3), **kwargs) - Comment.objects.create(date=date(2016, 1, 3), **kwargs) - results = F(data={ - 'published_0': '2016-01-02', - 'published_1': '2016-01-03'}) - self.assertEqual(len(results.qs), 3) - - @override_settings(USE_TZ=False) - def test_filtering_ignores_time(self): - class F(FilterSet): - published = DateFromToRangeFilter() - class Meta: - model = Article - fields = ['published'] - Article.objects.create(published=datetime(2016, 1, 1, 10, 0)) - Article.objects.create(published=datetime(2016, 1, 2, 12, 45)) - Article.objects.create(published=datetime(2016, 1, 3, 18, 15)) - Article.objects.create(published=datetime(2016, 1, 3, 19, 30)) - results = F(data={ - 'published_0': '2016-01-02', - 'published_1': '2016-01-03'}) - self.assertEqual(len(results.qs), 3) - class DateTimeFromToRangeFilterTests(TestCase): @@ -831,22 +798,6 @@ class DateTimeFromToRangeFilterTests(TestCase): qs.filter.assert_called_once_with( None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45))) - @override_settings(USE_TZ=False) - def test_filtering_queryset(self): - class F(FilterSet): - published = DateTimeFromToRangeFilter() - class Meta: - model = Article - fields = ['published'] - Article.objects.create(published=datetime(2016, 1, 1, 10, 0)) - Article.objects.create(published=datetime(2016, 1, 2, 12, 45)) - Article.objects.create(published=datetime(2016, 1, 3, 18, 15)) - Article.objects.create(published=datetime(2016, 1, 3, 19, 30)) - 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): From 69d63793a4d8a980f4276bd31ca33aca8f1fef1b Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 23 Feb 2016 19:31:23 +0100 Subject: [PATCH 50/60] FilterSetMetaclass: add the not defined field name in TypeError --- django_filters/filterset.py | 5 +++-- tests/test_filterset.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 8683979..5178dd0 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -163,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 diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 640fe1a..8876075 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -372,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): From 2e0311e5bd1acb9777199db9d9c0587bdc68cca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Thu, 25 Feb 2016 00:03:07 +0100 Subject: [PATCH 51/60] Add common function for handling time zone --- django_filters/fields.py | 24 ++++++------------------ django_filters/utils.py | 11 ++++++++++- tests/test_fields.py | 8 ++------ 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/django_filters/fields.py b/django_filters/fields.py index ef74238..62e28e8 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -5,14 +5,12 @@ 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.timezone import make_aware from django.utils.translation import ugettext_lazy as _ +from .utils import handle_timezone from .widgets import RangeWidget, LookupTypeWidget, CSVWidget @@ -44,13 +42,11 @@ class DateRangeField(RangeField): if data_list: start_date, stop_date = data_list if start_date: - start_date = datetime.combine(start_date, time.min) - if settings.USE_TZ: - start_date = make_aware(start_date) + start_date = handle_timezone( + datetime.combine(start_date, time.min)) if stop_date: - stop_date = datetime.combine(stop_date, time.max) - if settings.USE_TZ: - stop_date = make_aware(stop_date) + stop_date = handle_timezone( + datetime.combine(stop_date, time.max)) return slice(start_date, stop_date) return None @@ -111,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) @@ -120,14 +115,7 @@ 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) diff --git a/django_filters/utils.py b/django_filters/utils.py index e9cb373..0568a3d 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -1,9 +1,10 @@ - +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 @@ -93,3 +94,11 @@ def resolve_field(model_field, lookup_expr): 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 diff --git a/tests/test_fields.py b/tests/test_fields.py index 24a4b7d..1b1d6b4 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -8,7 +8,7 @@ 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 ( @@ -170,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)) @@ -186,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)) From f13899fb3280a719513b907af71b111b6fb2fc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Bogda=C5=82?= Date: Thu, 25 Feb 2016 00:07:44 +0100 Subject: [PATCH 52/60] Get rid of unnecessary imports --- tests/test_filters.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index 8aca9cb..092e1c1 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -6,11 +6,10 @@ import mock import warnings import unittest -import django from django import forms -from django.test import TestCase, override_settings +from django.test import TestCase -from django_filters import filters, FilterSet +from django_filters import filters from django_filters.fields import ( Lookup, RangeField, @@ -44,7 +43,7 @@ from django_filters.filters import ( UUIDFilter, LOOKUP_TYPES) -from tests.models import Book, User, Article, Comment +from tests.models import Book, User class FilterTests(TestCase): From 72c5d89535939749d7ee3dd4ecbabcec254933a6 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Sat, 27 Feb 2016 20:21:14 -0500 Subject: [PATCH 53/60] Fix travis badge in README --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ff7611e..5604138 100644 --- a/README.rst +++ b/README.rst @@ -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 ------------ From 13286b152446987c46364796f5d649e654876184 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 22 Feb 2016 16:50:09 -0500 Subject: [PATCH 54/60] Fix boolean widget rendering --- django_filters/widgets.py | 42 ++++++++++++++++++++++++++++----------- tests/test_widgets.py | 9 +++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/django_filters/widgets.py b/django_filters/widgets.py index c13fc21..04a5678 100644 --- a/django_filters/widgets.py +++ b/django_filters/widgets.py @@ -98,24 +98,42 @@ 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() - if value is not None: - if value.lower() == 'true': - value = True - elif value.lower() == 'false': - value = False - - return value + return { + '1': True, + '0': False, + 'true': True, + 'false': False, + True: True, + False: False, + }.get(value, None) class CSVWidget(forms.TextInput): diff --git a/tests/test_widgets.py b/tests/test_widgets.py index b92d9ba..f8873c3 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -149,6 +149,15 @@ class RangeWidgetTests(TestCase): class BooleanWidgetTests(TestCase): """ """ + def test_widget_render(self): + w = BooleanWidget() + self.assertHTMLEqual(w.render('price', ''), """ + """) + def test_widget_value_from_datadict(self): """ """ From 614c985a5bbab01e5a03bb44edb3a480cdf86aa2 Mon Sep 17 00:00:00 2001 From: Rik Date: Fri, 4 Mar 2016 12:36:53 +0100 Subject: [PATCH 55/60] updated docs to use pip instead of manual install --- docs/install.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/install.txt b/docs/install.txt index 11bf56e..6265241 100644 --- a/docs/install.txt +++ b/docs/install.txt @@ -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``. From b8cd265073445fcc555bc202a7adb215d9e004c7 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 9 Mar 2016 11:35:55 -0500 Subject: [PATCH 56/60] Revert docs theme back to 'default' --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 7dfa679..d47acc5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,7 +91,7 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'classic' +html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From da35aa60f0baf87f302e738c580add44de5d9cb1 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Mon, 4 Jan 2016 07:00:45 +0100 Subject: [PATCH 57/60] Add settings for help_text --- django_filters/filters.py | 9 ++++++++- tests/test_filters.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/django_filters/filters.py b/django_filters/filters.py index 486c2c4..cb08c47 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -8,6 +8,7 @@ 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 _ @@ -83,7 +84,13 @@ class Filter(object): 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.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))): diff --git a/tests/test_filters.py b/tests/test_filters.py index 092e1c1..6b2db80 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -7,7 +7,7 @@ import warnings import unittest 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 ( @@ -70,6 +70,20 @@ class FilterTests(TestCase): self.assertIsInstance(field, forms.Field) self.assertEqual(field.help_text, 'This is an exclusion filter') + @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 From 5a18fe72b80a302daad065cf144206f51cf459c2 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Sun, 10 Jan 2016 05:27:00 +0100 Subject: [PATCH 58/60] Add docs for settings --- docs/index.txt | 1 + docs/ref/settings.txt | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 docs/ref/settings.txt diff --git a/docs/index.txt b/docs/index.txt index 61626bc..cf8f7fd 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -18,4 +18,5 @@ Contents: ref/filters ref/fields ref/widgets + ref/settings tests diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt new file mode 100644 index 0000000..7fba919 --- /dev/null +++ b/docs/ref/settings.txt @@ -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`` + +The settings for turn on / off default ``help_text`` fields of filters which ``exclude=True`` (see :doc:`/ref/filters`.). It doesn't affect on fields which defined ``help_text`` explicte. + +FILTERS_HELP_TEXT_FILTER +------------------------ + +Default: ``True`` + +The settings for turn on / off default ``help_text`` fields of filters which ``exclude=False`` (default; see :doc:`/ref/filters`). It doesn't affect on fields which defined ``help_text`` explicte. From b585a47975ec216433372c0e3b4e9038bfd1fa90 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 11 Mar 2016 00:03:39 -0500 Subject: [PATCH 59/60] Update settings docs language --- docs/ref/settings.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 7fba919..760442d 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -14,11 +14,11 @@ FILTERS_HELP_TEXT_EXCLUDE Default: ``True`` -The settings for turn on / off default ``help_text`` fields of filters which ``exclude=True`` (see :doc:`/ref/filters`.). It doesn't affect on fields which defined ``help_text`` explicte. +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`` -The settings for turn on / off default ``help_text`` fields of filters which ``exclude=False`` (default; see :doc:`/ref/filters`). It doesn't affect on fields which defined ``help_text`` explicte. +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``. From ba92f71fecd244ea7ac140159b77e83a2ea41217 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Fri, 11 Mar 2016 20:38:25 +0100 Subject: [PATCH 60/60] Prepare for 0.13 release --- .bumpversion.cfg | 2 +- CHANGES.rst | 12 ++++++++++++ MANIFEST.in | 1 + README.rst | 2 +- django_filters/__init__.py | 2 +- docs/conf.py | 4 ++-- requirements/docs.txt | 1 - requirements/maintainer.txt | 34 ++++++++++++++++++++++++++++++---- setup.py | 6 ++++-- 9 files changed, 52 insertions(+), 12 deletions(-) delete mode 100644 requirements/docs.txt diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 19df5fa..65c6c1b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.12.0 +current_version = 0.13.0 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/CHANGES.rst b/CHANGES.rst index 2e73a8a..ed3eb04 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,15 @@ +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) --------------------------- diff --git a/MANIFEST.in b/MANIFEST.in index bdd47ea..13d96d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,3 +8,4 @@ recursive-include docs * recursive-include requirements * recursive-include tests * recursive-include django_filters/locale * +prune docs/_build \ No newline at end of file diff --git a/README.rst b/README.rst index 5604138..19ec278 100644 --- a/README.rst +++ b/README.rst @@ -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 ----- diff --git a/django_filters/__init__.py b/django_filters/__init__.py index 035806e..eeb0868 100644 --- a/django_filters/__init__.py +++ b/django_filters/__init__.py @@ -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): diff --git a/docs/conf.py b/docs/conf.py index d47acc5..e6d2650 100644 --- a/docs/conf.py +++ b/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. diff --git a/requirements/docs.txt b/requirements/docs.txt deleted file mode 100644 index 2806c16..0000000 --- a/requirements/docs.txt +++ /dev/null @@ -1 +0,0 @@ -Sphinx diff --git a/requirements/maintainer.txt b/requirements/maintainer.txt index 10d4e8c..b9bbbbf 100644 --- a/requirements/maintainer.txt +++ b/requirements/maintainer.txt @@ -1,4 +1,30 @@ -bumpversion -twine==1.5.0 -wheel==0.24.0 -coverage==3.7.1 \ No newline at end of file +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 diff --git a/setup.py b/setup.py index a4054d1..123ada3 100644 --- a/setup.py +++ b/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': [