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 f7f3b13..ed3eb04 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,20 @@ +Version 0.13.0 (2016-03-11) +--------------------------- + +* Add support for filtering by CSV #363 + +* Add DateTimeFromToRangeFilter #376 + +* Add Chinese translation #359 + +* Lots of fixes. + + Version 0.12.0 (2016-01-07) --------------------------- +* Raised minimum Django version to 1.8.x + * FEATURE: Add support for custom ORM lookup types #221 * FEATURE: Add JavaScript friendly BooleanWidget #270 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 bd10d50..19ec278 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 ------------ @@ -24,7 +24,7 @@ Install using pip:: Or clone the repo and add to your PYTHONPATH:: - git clone git@github.com:alex/django-filter.git + git clone git@github.com:carltongibson/django-filter.git Usage ----- @@ -46,7 +46,7 @@ And then in your view you could do:: def product_list(request): filter = ProductFilter(request.GET, queryset=Product.objects.all()) - return render_to_response('my_app/template.html', {'filter': filter}) + return render(request, 'my_app/template.html', {'filter': filter}) Django-filters additionally supports specifying FilterSet fields using a dictionary to specify filters with lookup types:: 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/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/fields.py b/django_filters/fields.py index 3dee7ae..62e28e8 100644 --- a/django_filters/fields.py +++ b/django_filters/fields.py @@ -5,13 +5,13 @@ from datetime import datetime, time from collections import namedtuple from django import forms -from django.conf import settings from django.utils.dateparse import parse_datetime -from django.utils import timezone from django.utils.encoding import force_str +from django.utils.translation import ugettext_lazy as _ -from .widgets import RangeWidget, LookupTypeWidget +from .utils import handle_timezone +from .widgets import RangeWidget, LookupTypeWidget, CSVWidget class RangeField(forms.MultiValueField): @@ -42,13 +42,24 @@ class DateRangeField(RangeField): if data_list: start_date, stop_date = data_list if start_date: - start_date = datetime.combine(start_date, time.min) + start_date = handle_timezone( + datetime.combine(start_date, time.min)) if stop_date: - stop_date = datetime.combine(stop_date, time.max) + stop_date = handle_timezone( + datetime.combine(stop_date, time.max)) return slice(start_date, stop_date) return None +class DateTimeRangeField(RangeField): + + def __init__(self, *args, **kwargs): + fields = ( + forms.DateTimeField(), + forms.DateTimeField()) + super(DateTimeRangeField, self).__init__(fields, *args, **kwargs) + + class TimeRangeField(RangeField): def __init__(self, *args, **kwargs): @@ -58,7 +69,12 @@ class TimeRangeField(RangeField): super(TimeRangeField, self).__init__(fields, *args, **kwargs) -Lookup = namedtuple('Lookup', ('value', 'lookup_type')) +class Lookup(namedtuple('Lookup', ('value', 'lookup_type'))): + # python nature is test __len__ on tuple types for boolean check + def __len__(self): + if not self.value: + return 0 + return 2 class LookupTypeField(forms.MultiValueField): @@ -91,7 +107,6 @@ class IsoDateTimeField(forms.DateTimeField): """ ISO_8601 = 'iso-8601' input_formats = [ISO_8601] - default_timezone = timezone.get_default_timezone() if settings.USE_TZ else None def strptime(self, value, format): value = force_str(value) @@ -100,12 +115,39 @@ class IsoDateTimeField(forms.DateTimeField): parsed = parse_datetime(value) if parsed is None: # Continue with other formats if doesn't match raise ValueError - - # Handle timezone awareness. Copied from: - # https://github.com/tomchristie/django-rest-framework/blob/3.2.0/rest_framework/fields.py#L965-L969 - if settings.USE_TZ and not timezone.is_aware(parsed): - return timezone.make_aware(parsed, self.default_timezone) - elif not settings.USE_TZ and timezone.is_aware(parsed): - return timezone.make_naive(parsed, timezone.UTC()) - return parsed + return handle_timezone(parsed) return super(IsoDateTimeField, self).strptime(value, format) + + +class BaseCSVField(forms.Field): + """ + Base field for validating CSV types. Value validation is performed by + secondary base classes. + + ex:: + class IntegerCSVField(BaseCSVField, filters.IntegerField): + pass + + """ + widget = CSVWidget + + def clean(self, value): + if value is None: + return None + return [super(BaseCSVField, self).clean(v) for v in value] + + +class BaseRangeField(BaseCSVField): + default_error_messages = { + 'invalid_values': _('Range query expects two values.') + } + + def clean(self, value): + value = super(BaseRangeField, self).clean(value) + + if value is not None and len(value) != 2: + raise forms.ValidationError( + self.error_messages['invalid_values'], + code='invalid_values') + + return value diff --git a/django_filters/filters.py b/django_filters/filters.py index d718915..cb08c47 100644 --- a/django_filters/filters.py +++ b/django_filters/filters.py @@ -1,18 +1,22 @@ from __future__ import absolute_import from __future__ import unicode_literals +import warnings from datetime import timedelta - from django import forms from django.db.models import Q from django.db.models.sql.constants import QUERY_TERMS +from django.db.models.constants import LOOKUP_SEP +from django.conf import settings from django.utils import six from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from .fields import ( - RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField) + Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, + DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField +) __all__ = [ @@ -20,25 +24,34 @@ __all__ = [ 'TypedChoiceFilter', 'MultipleChoiceFilter', 'DateFilter', 'DateTimeFilter', 'IsoDateTimeFilter', 'TimeFilter', 'ModelChoiceFilter', 'ModelMultipleChoiceFilter', 'NumberFilter', 'NumericRangeFilter', 'RangeFilter', - 'DateRangeFilter', 'DateFromToRangeFilter', 'TimeRangeFilter', - 'AllValuesFilter', 'MethodFilter' + 'DateRangeFilter', 'DateFromToRangeFilter', 'DateTimeFromToRangeFilter', + 'TimeRangeFilter', 'AllValuesFilter', 'MethodFilter' ] LOOKUP_TYPES = sorted(QUERY_TERMS) +def _lookup_type_warning(): + warnings.warn('lookup_type is deprecated. Use lookup_expr instead.', DeprecationWarning, stacklevel=3) + + class Filter(object): creation_counter = 0 field_class = forms.Field def __init__(self, name=None, label=None, widget=None, action=None, - lookup_type='exact', required=False, distinct=False, exclude=False, **kwargs): + lookup_expr='exact', required=False, distinct=False, exclude=False, **kwargs): self.name = name self.label = label if action: self.filter = action - self.lookup_type = lookup_type + + self.lookup_expr = lookup_expr + if 'lookup_type' in kwargs: + _lookup_type_warning() + self.lookup_expr = kwargs.pop('lookup_type') + self.widget = widget self.required = required self.extra = kwargs @@ -54,14 +67,32 @@ class Filter(object): """ return qs.exclude if self.exclude else qs.filter + def lookup_type(): + def fget(self): + _lookup_type_warning() + return self.lookup_expr + + def fset(self, value): + _lookup_type_warning() + self.lookup_expr = value + + return locals() + lookup_type = property(**lookup_type()) + @property def field(self): if not hasattr(self, '_field'): help_text = self.extra.pop('help_text', None) if help_text is None: - help_text = _('This is an exclusion filter') if self.exclude else _('Filter') - if (self.lookup_type is None or - isinstance(self.lookup_type, (list, tuple))): + if self.exclude and getattr(settings, "FILTERS_HELP_TEXT_EXCLUDE", True): + help_text = _('This is an exclusion filter') + elif not self.exclude and getattr(settings, "FILTERS_HELP_TEXT_FILTER", True): + help_text = _('Filter') + else: + help_text = '' + + if (self.lookup_expr is None or + isinstance(self.lookup_expr, (list, tuple))): lookup = [] @@ -71,10 +102,15 @@ class Filter(object): else: choice = (x, x) - if self.lookup_type is None: - lookup.append(choice) - elif x in self.lookup_type: + if self.lookup_expr is None: lookup.append(choice) + else: + if isinstance(x, (list, tuple)) and len(x) == 2: + if x[0] in self.lookup_expr: + lookup.append(choice) + else: + if x in self.lookup_expr: + lookup.append(choice) self._field = LookupTypeField(self.field_class( required=self.required, widget=self.widget, **self.extra), @@ -90,7 +126,7 @@ class Filter(object): lookup = six.text_type(value.lookup_type) value = value.value else: - lookup = self.lookup_type + lookup = self.lookup_expr if value in ([], (), {}, None, ''): return qs if self.distinct: @@ -229,7 +265,7 @@ class NumericRangeFilter(Filter): def filter(self, qs, value): if value: if value.start is not None and value.stop is not None: - lookup = '%s__%s' % (self.name, self.lookup_type) + lookup = '%s__%s' % (self.name, self.lookup_expr) return self.get_method(qs)(**{lookup: (value.start, value.stop)}) else: if value.start is not None: @@ -305,6 +341,10 @@ class DateFromToRangeFilter(RangeFilter): field_class = DateRangeField +class DateTimeFromToRangeFilter(RangeFilter): + field_class = DateTimeRangeField + + class TimeRangeFilter(RangeFilter): field_class = TimeRangeField @@ -318,6 +358,65 @@ class AllValuesFilter(ChoiceFilter): return super(AllValuesFilter, self).field +class BaseCSVFilter(Filter): + """ + Base class for CSV type filters, such as IN and RANGE. + """ + base_field_class = BaseCSVField + + def __init__(self, *args, **kwargs): + super(BaseCSVFilter, self).__init__(*args, **kwargs) + + class ConcreteCSVField(self.base_field_class, self.field_class): + pass + ConcreteCSVField.__name__ = self._field_class_name( + self.field_class, self.lookup_expr + ) + + self.field_class = ConcreteCSVField + + @classmethod + def _field_class_name(cls, field_class, lookup_expr): + """ + Generate a suitable class name for the concrete field class. This is not + completely reliable, as not all field class names are of the format + 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/django_filters/filterset.py b/django_filters/filterset.py index 686c8be..5178dd0 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -10,15 +10,16 @@ from django.forms.forms import NON_FIELD_ERRORS from django.core.validators import EMPTY_VALUES from django.db import models from django.db.models.constants import LOOKUP_SEP -from django.db.models.fields import FieldDoesNotExist from django.db.models.fields.related import ForeignObjectRel from django.utils import six from django.utils.text import capfirst from django.utils.translation import ugettext as _ -from .filters import (Filter, CharFilter, BooleanFilter, +from .compat import remote_field, remote_model +from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter, ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, UUIDFilter) +from .utils import try_dbfield, get_model_field, resolve_field ORDER_BY_FIELD = 'o' @@ -56,25 +57,6 @@ def get_declared_filters(bases, attrs, with_base_filters=True): return OrderedDict(filters) -def get_model_field(model, f): - parts = f.split(LOOKUP_SEP) - opts = model._meta - for name in parts[:-1]: - try: - rel = opts.get_field(name) - except FieldDoesNotExist: - return None - if isinstance(rel, ForeignObjectRel): - opts = rel.related_model._meta - else: - opts = rel.rel.to._meta - try: - rel = opts.get_field(parts[-1]) - except FieldDoesNotExist: - return None - return rel - - def filters_for_model(model, fields=None, exclude=None, filter_for_field=None, filter_for_reverse_field=None): field_dict = OrderedDict() @@ -99,14 +81,17 @@ def filters_for_model(model, fields=None, exclude=None, filter_for_field=None, # If fields is a dictionary, it must contain lists. elif isinstance(fields, dict): # Create a filter for each lookup type. - for lookup_type in fields[f]: - filter_ = filter_for_field(field, f, lookup_type) + for lookup_expr in fields[f]: + filter_ = filter_for_field(field, f, lookup_expr) if filter_: - filter_name = f + filter_name = LOOKUP_SEP.join([f, lookup_expr]) + # Don't add "exact" to filter names - if lookup_type != 'exact': - filter_name = f + LOOKUP_SEP + lookup_type + _exact = LOOKUP_SEP + 'exact' + if filter_name.endswith(_exact): + filter_name = filter_name[:-len(_exact)] + field_dict[filter_name] = filter_ # If fields is a list, it contains strings. else: @@ -178,9 +163,10 @@ class FilterSetMetaclass(type): else: filters = declared_filters - if None in filters.values(): + not_defined = next((k for k, v in filters.items() if v is None), False) + if not_defined: raise TypeError("Meta.fields contains a field that isn't defined " - "on this FilterSet") + "on this FilterSet: {}".format(not_defined)) new_class.declared_filters = declared_filters new_class.base_filters = filters @@ -212,24 +198,24 @@ FILTER_FOR_DBFIELD_DEFAULTS = { models.OneToOneField: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { - 'queryset': f.rel.to._default_manager.complex_filter( - f.rel.limit_choices_to), - 'to_field_name': f.rel.field_name, + 'queryset': remote_model(f)._default_manager.complex_filter( + remote_field(f).limit_choices_to), + 'to_field_name': remote_field(f).field_name, } }, models.ForeignKey: { 'filter_class': ModelChoiceFilter, 'extra': lambda f: { - 'queryset': f.rel.to._default_manager.complex_filter( - f.rel.limit_choices_to), - 'to_field_name': f.rel.field_name + 'queryset': remote_model(f)._default_manager.complex_filter( + remote_field(f).limit_choices_to), + 'to_field_name': remote_field(f).field_name } }, models.ManyToManyField: { 'filter_class': ModelMultipleChoiceFilter, 'extra': lambda f: { - 'queryset': f.rel.to._default_manager.complex_filter( - f.rel.limit_choices_to), + 'queryset': remote_model(f)._default_manager.complex_filter( + remote_field(f).limit_choices_to), } }, models.DecimalField: { @@ -355,7 +341,9 @@ class BaseFilterSet(object): except forms.ValidationError: pass - if ordered_value in EMPTY_VALUES and self.strict: + # With a None-queryset, ordering must be enforced (#84). + if (ordered_value in EMPTY_VALUES and + self.strict == STRICTNESS.RETURN_NO_RESULTS): ordered_value = self.form.fields[self.order_by_field].choices[0][0] if ordered_value: @@ -430,41 +418,24 @@ class BaseFilterSet(object): return [order_choice] @classmethod - def filter_for_field(cls, f, name, lookup_type='exact'): - filter_for_field = dict(FILTER_FOR_DBFIELD_DEFAULTS) - filter_for_field.update(cls.filter_overrides) + def filter_for_field(cls, f, name, lookup_expr='exact'): + f, lookup_type = resolve_field(f, lookup_expr) default = { 'name': name, 'label': capfirst(f.verbose_name), - 'lookup_type': lookup_type + 'lookup_expr': lookup_expr } - if f.choices: - default['choices'] = f.choices - return ChoiceFilter(**default) + filter_class, params = cls.filter_for_lookup(f, lookup_type) + default.update(params) - data = filter_for_field.get(f.__class__) - if data is None: - # could be a derived field, inspect parents - for class_ in f.__class__.mro(): - # skip if class_ is models.Field or object - # 1st item in mro() is original class - if class_ in (f.__class__, models.Field, object): - continue - data = filter_for_field.get(class_) - if data: - break - if data is None: - return - filter_class = data.get('filter_class') - default.update(data.get('extra', lambda f: {})(f)) if filter_class is not None: return filter_class(**default) @classmethod def filter_for_reverse_field(cls, f, name): - rel = f.field.rel + rel = remote_field(f.field) queryset = f.field.model._default_manager.all() default = { 'name': name, @@ -476,6 +447,76 @@ class BaseFilterSet(object): else: return ModelChoiceFilter(**default) + @classmethod + def filter_for_lookup(cls, f, lookup_type): + DEFAULTS = dict(FILTER_FOR_DBFIELD_DEFAULTS) + DEFAULTS.update(cls.filter_overrides) + + data = try_dbfield(DEFAULTS.get, f.__class__) or {} + filter_class = data.get('filter_class') + params = data.get('extra', lambda f: {})(f) + + # if there is no filter class, exit early + if not filter_class: + return None, {} + + # perform lookup specific checks + if lookup_type == 'isnull': + data = try_dbfield(DEFAULTS.get, models.BooleanField) + + filter_class = data.get('filter_class') + params = data.get('extra', lambda f: {})(f) + return filter_class, params + + if lookup_type == 'in': + class ConcreteInFilter(BaseInFilter, filter_class): + pass + ConcreteInFilter.__name__ = cls._csv_filter_class_name( + filter_class, lookup_type + ) + + return ConcreteInFilter, params + + if lookup_type == 'range': + class ConcreteRangeFilter(BaseRangeFilter, filter_class): + pass + ConcreteRangeFilter.__name__ = cls._csv_filter_class_name( + filter_class, lookup_type + ) + + return ConcreteRangeFilter, params + + # Default behavior + if f.choices: + return ChoiceFilter, {'choices': f.choices} + + return filter_class, params + + @classmethod + def _csv_filter_class_name(cls, filter_class, lookup_type): + """ + Generate a suitable class name for a concrete filter class. This is not + completely reliable, as not all filter class names are of the format + 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 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 "全部" diff --git a/django_filters/utils.py b/django_filters/utils.py new file mode 100644 index 0000000..0568a3d --- /dev/null +++ b/django_filters/utils.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db import models +from django.db.models.constants import LOOKUP_SEP +from django.db.models.expressions import Expression +from django.db.models.fields import FieldDoesNotExist +from django.db.models.fields.related import ForeignObjectRel +from django.utils import timezone + +from .compat import remote_model + + +def try_dbfield(fn, field_class): + """ + Try ``fn`` with the DB ``field_class`` by walking its + MRO until a result is found. + + ex:: + _try_dbfield(field_dict.get, models.CharField) + + """ + # walk the mro, as field_class could be a derived model field. + for cls in field_class.mro(): + # skip if cls is models.Field + if cls is models.Field: + continue + + data = fn(cls) + if data: + return data + + +def get_model_field(model, field_name): + """ + Get a ``model`` field, traversing relationships + in the ``field_name``. + + ex:: + + f = get_model_field(Book, 'author__first_name') + + """ + parts = field_name.split(LOOKUP_SEP) + opts = model._meta + + # walk relationships + for name in parts[:-1]: + try: + rel = opts.get_field(name) + except FieldDoesNotExist: + return None + if isinstance(rel, ForeignObjectRel): + opts = rel.related_model._meta + else: + opts = remote_model(rel)._meta + + try: + return opts.get_field(parts[-1]) + except FieldDoesNotExist: + return None + + +def resolve_field(model_field, lookup_expr): + """ + Resolves a ``lookup_expr`` into its final output field, given + the initial ``model_field``. The lookup expression should only contain + transforms and lookups, not intermediary model field parts. + + Note: + This method is based on django.db.models.sql.query.Query.build_lookup + + For more info on the lookup API: + https://docs.djangoproject.com/en/1.9/ref/models/lookups/ + + """ + query = model_field.model._default_manager.all().query + lhs = Expression(model_field) + lookups = lookup_expr.split(LOOKUP_SEP) + + assert len(lookups) > 0 + + while lookups: + name = lookups[0] + # If there is just one part left, try first get_lookup() so + # that if the lhs supports both transform and lookup for the + # name, then lookup will be picked. + if len(lookups) == 1: + final_lookup = lhs.get_lookup(name) + if not final_lookup: + # We didn't find a lookup. We are going to interpret + # the name as transform, and do an Exact lookup against + # it. + lhs = query.try_transform(lhs, name, lookups) + final_lookup = lhs.get_lookup('exact') + return lhs.output_field, final_lookup.lookup_name + lhs = query.try_transform(lhs, name, lookups) + lookups = lookups[1:] + + +def handle_timezone(value): + if settings.USE_TZ and timezone.is_naive(value): + return timezone.make_aware(value, timezone.get_default_timezone()) + elif not settings.USE_TZ and timezone.is_aware(value): + return timezone.make_naive(value, timezone.UTC()) + return value diff --git a/django_filters/widgets.py b/django_filters/widgets.py index 55ada54..04a5678 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 @@ -12,6 +13,7 @@ from django.db.models.fields import BLANK_CHOICE_DASH from django.forms.widgets import flatatt from django.utils.encoding import force_text from django.utils.safestring import mark_safe +from django.utils.six import string_types from django.utils.translation import ugettext as _ @@ -96,21 +98,58 @@ class LookupTypeWidget(forms.MultiWidget): return value -class BooleanWidget(forms.Widget): +class BooleanWidget(forms.Select): """Convert true/false values into the internal Python True/False. This can be used for AJAX queries that pass true/false from JavaScript's internal types through. """ + def __init__(self, attrs=None): + choices = (('', _('Unknown')), + ('true', _('Yes')), + ('false', _('No'))) + super(BooleanWidget, self).__init__(attrs, choices) + + def render(self, name, value, attrs=None): + try: + value = { + True: 'true', + False: 'false', + '1': 'true', + '0': 'false' + }[value] + except KeyError: + value = '' + return super(BooleanWidget, self).render(name, value, attrs) + def value_from_datadict(self, data, files, name): - """ - """ - value = super(BooleanWidget, self).value_from_datadict( - data, files, name) + value = data.get(name, None) + if isinstance(value, string_types): + value = value.lower() + + return { + '1': True, + '0': False, + 'true': True, + 'false': False, + True: True, + False: False, + }.get(value, None) + + +class CSVWidget(forms.TextInput): + def _isiterable(self, value): + return isinstance(value, Iterable) and not isinstance(value, string_types) + + def value_from_datadict(self, data, files, name): + value = super(CSVWidget, self).value_from_datadict(data, files, name) if value is not None: - if value.lower() == 'true': - value = True - elif value.lower() == 'false': - value = False + return value.split(',') + return None - return value + def render(self, name, value, attrs=None): + if self._isiterable(value): + value = [force_text(self._format_value(v)) for v in value] + value = ','.join(list(value)) + + return super(CSVWidget, self).render(name, value, attrs) diff --git a/docs/conf.py b/docs/conf.py index 53e01e1..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. @@ -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'), ] diff --git a/docs/index.txt b/docs/index.txt index ba29257..cf8f7fd 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -14,7 +14,9 @@ Contents: install usage + ref/filterset ref/filters ref/fields ref/widgets + ref/settings tests 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``. 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..4cd2186 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -3,6 +3,124 @@ Filter Reference This is a reference document with a list of the filters and their arguments. +.. _core-arguments: + +Core Arguments +-------------- + +The following are the core arguments that apply to all filters. + +``name`` +~~~~~~~~ + +The name of the field this filter is supposed to filter on, if this is not +provided it automatically becomes the filter's name on the ``FilterSet``. +You can traverse "relationship paths" using Django's ``__`` syntax to filter +fields on a related model. eg, ``manufacturer__name``. + +``label`` +~~~~~~~~~ + +The label as it will apear in the HTML, analogous to a form field's label +argument. + +``widget`` +~~~~~~~~~~ + +The django.form Widget class which will represent the ``Filter``. In addition +to the widgets that are included with Django that you can use there are +additional ones that django-filter provides which may be useful: + + * :ref:`LinkWidget ` -- 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`` +~~~~~~~~~~ + +An optional callable that tells the filter how to handle the queryset. It +recieves a ``QuerySet`` and the value to filter on and should return a +``Queryset`` that is filtered appropriately. `action` will default to +``filter_{value-of-name-attribute}`` + +``lookup_expr`` +~~~~~~~~~~~~~~~ + +The lookup expression that should be performed using `Django's ORM`_. + +.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups + +A ``list`` or ``tuple`` of lookup types is also accepted, allowing the user to +select the lookup from a dropdown. The list of lookup types are filtered against +``filters.LOOKUP_TYPES``. If `lookup_expr=None` is passed, then a list of all lookup +types will be generated:: + + class ProductFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr=['exact', 'iexact']) + +You can enable custom lookups by adding them to ``LOOKUP_TYPES``:: + + from django_filters import filters + + filters.LOOKUP_TYPES = ['gt', 'gte', 'lt', 'lte', 'custom_lookup_type'] + +Additionally, you can provide human-friendly help text by overriding ``LOOKUP_TYPES``:: + + # filters.py + from django_filters import filters + + filters.LOOKUP_TYPES = [ + ('', '---------'), + ('exact', 'Is equal to'), + ('not_exact', 'Is not equal to'), + ('lt', 'Lesser than'), + ('gt', 'Greater than'), + ('gte', 'Greater than or equal to'), + ('lte', 'Lesser than or equal to'), + ('startswith', 'Starts with'), + ('endswith', 'Ends with'), + ('contains', 'Contains'), + ('not_contains', 'Does not contain'), + ] + + +``distinct`` +~~~~~~~~~~~~ + +A boolean value that specifies whether the Filter will use distinct on the +queryset. This option can be used to eliminate duplicate results when using filters that span related models. Defaults to ``False``. + +``exclude`` +~~~~~~~~~~~ + +A boolean value that specifies whether the Filter should use ``filter`` or ``exclude`` on the queryset. +Defaults to ``False``. + + +``**kwargs`` +~~~~~~~~~~~~ + +Any additional keyword arguments are stored as the ``extra`` parameter on the filter. They are provided to the accompanying form Field and can be used to provide arguments like ``choices``. + + +ModelChoiceFilter and ModelMultipleChoiceFilter arguments +--------------------------------------------------------- + +These arguments apply specifically to ModelChoiceFiler and +ModelMultipleChoiceFilter only. + +``queryset`` +~~~~~~~~~~~~ + +``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg. + + Filters ------- @@ -171,7 +289,7 @@ than a maximum where only one limit value is provided. This filter is designed t the Postgres Numerical Range Fields, including `IntegerRangeField`, `BigIntegerRangeField` and `FloatRangeField`, available since Django 1.8. The default widget used is the `RangeField`. -RangeField lookup_types can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be +RangeField lookup_exprs can be used, including `overlap`, `contains`, and `contained_by`. More lookups can be found in the Django docs ([https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields](https://docs.djangoproject.com/en/1.8/ref/contrib/postgres/fields/#querying-range-fields)). If the lower limit value is provided, the filter automatically defaults to `__startswith` as the lookup @@ -211,14 +329,118 @@ selections for working with date fields. ``DateFromToRangeFilter`` ~~~~~~~~~~~~~~~~~~~~~~~~~ -Similar to a ``RangeFilter`` except it uses dates instead of numerical values. It can be used with ``DateField`` and ``DateTimeField``. +Similar to a ``RangeFilter`` except it uses dates instead of numerical values. It can be used with ``DateField``. It also works with ``DateTimeField``, but takes into consideration only the date. +Example of using the ``DateField`` field:: + + class Comment(models.Model): + date = models.DateField() + time = models.TimeField() + + class F(FilterSet): + date = DateFromToRangeFilter() + + class Meta: + model = Comment + fields = ['date'] + + # Range: Comments added between 2016-01-01 and 2016-02-01 + f = F({'date_0': '2016-01-01', 'date_1': '2016-02-01'}) + + # Min-Only: Comments added after 2016-01-01 + f = F({'date_0': '2016-01-01'}) + + # Max-Only: Comments added before 2016-02-01 + f = F({'date_1': '2016-02-01'}) + +Example of using the ``DateTimeField`` field:: + + class Article(models.Model): + published = models.DateTimeField() + + class F(FilterSet): + published = DateFromToRangeFilter() + + class Meta: + model = Article + fields = ['published'] + + Article.objects.create(published='2016-01-01 8:00') + Article.objects.create(published='2016-01-20 10:00') + Article.objects.create(published='2016-02-10 12:00') + + # Range: Articles published between 2016-01-01 and 2016-02-01 + f = F({'published_0': '2016-01-01', 'published_1': '2016-02-01'}) + assert len(f.qs) == 2 + + # Min-Only: Articles published after 2016-01-01 + f = F({'published_0': '2016-01-01'}) + assert len(f.qs) == 3 + + # Max-Only: Articles published before 2016-02-01 + f = F({'published_1': '2016-02-01'}) + assert len(f.qs) == 2 + +``DateTimeFromToRangeFilter`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Similar to a ``RangeFilter`` except it uses datetime format values instead of numerical values. It can be used with ``DateTimeField``. + +Example:: + + class Article(models.Model): + published = models.DateTimeField() + + class F(FilterSet): + published = DateTimeFromToRangeFilter() + + class Meta: + model = Article + fields = ['published'] + + Article.objects.create(published='2016-01-01 8:00') + Article.objects.create(published='2016-01-01 9:30') + Article.objects.create(published='2016-01-02 8:00') + + # Range: Articles published 2016-01-01 between 8:00 and 10:00 + f = F({'published_0': '2016-01-01 8:00', 'published_1': '2016-01-01 10:00'}) + assert len(f.qs) == 2 + + # Min-Only: Articles published after 2016-01-01 8:00 + f = F({'published_0': '2016-01-01 8:00'}) + assert len(f.qs) == 3 + + # Max-Only: Articles published before 2016-01-01 10:00 + f = F({'published_1': '2016-01-01 10:00'}) + assert len(f.qs) == 2 ``TimeRangeFilter`` ~~~~~~~~~~~~~~~~~~~ Similar to a ``RangeFilter`` except it uses time format values instead of numerical values. It can be used with ``TimeField``. +Example:: + + class Comment(models.Model): + date = models.DateField() + time = models.TimeField() + + class F(FilterSet): + time = TimeRangeFilter() + + class Meta: + model = Comment + fields = ['time'] + + # Range: Comments added between 8:00 and 10:00 + f = F({'time_0': '8:00', 'time_1': '10:00'}) + + # Min-Only: Comments added after 8:00 + f = F({'time_0': '8:00'}) + + # Max-Only: Comments added before 10:00 + f = F({'time_1': '10:00'}) + ``AllValuesFilter`` ~~~~~~~~~~~~~~~~~~~ @@ -227,9 +449,60 @@ database. So if in the DB for the given field you have values of 5, 7, and 9 each of those is present as an option. This is similar to the default behavior of the admin. -``MethodFilter`` +``BaseInFilter`` +~~~~~~~~~~~~~~~~ + +This is a base class used for generating IN lookup filters. Values are expected to be comma separated. + +Example. :: + + class NumberInFilter(BaseInFilter, NumericFilter): + pass + + class F(FilterSet): + id__in = NumberInFilter(name=id, lookup_expr='in') + + class Meta: + model = User + + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + User.objects.create(username='carl') + + # In: User with IDs 1 and 3. + f = F({'id__in': '1,3'}) + assert len(f.qs) == 2 + +``BaseRangeFilter`` ~~~~~~~~~~~~~~~~~~~ +This is a base class used for generating RANGE lookup filters. The base class expects two values two, separated by a comma. + +Example. :: + + class NumberRangeFilter(BaseInFilter, NumericFilter): + pass + + class F(FilterSet): + id__range = NumberRangeFilter(name=id, lookup_expr='range') + + class Meta: + model = User + + User.objects.create(username='alex') + User.objects.create(username='jacob') + User.objects.create(username='aaron') + User.objects.create(username='carl') + + # Range: User with IDs between 1 and 3. + f = F({'id__range': '1,3'}) + assert len(f.qs) == 3 + + +``MethodFilter`` +~~~~~~~~~~~~~~~~ + This is a ``Filter`` that will allow you to run a method that exists on the filter set that this filter is a property of. Set the `action` to a string that will map to a method on the filter set class. `action` will default to ``filter_{value-of-name-attribute}`` @@ -248,79 +521,3 @@ Example. :: class Meta: model = Book fields = ['published'] - -Core Arguments --------------- - -These arguments apply to the filters described above. - -``name`` -~~~~~~~~ - -The name of the field this filter is supposed to filter on, if this is not -provided it automatically becomes the filter's name on the ``FilterSet``. - -``label`` -~~~~~~~~~ - -The label as it will apear in the HTML, analogous to a form field's label -argument. - -``widget`` -~~~~~~~~~~ - -The django.form Widget class which will represent the ``Filter``. In addition -to the widgets that are included with Django that you can use there are -additional ones that django-filter provides which may be useful: - - * ``django_filters.widgets.LinkWidget`` -- this displays the options in a - manner similar to the way the Django Admin does, as a series of links. - The link for the selected option will have ``class="selected"``. - -``action`` -~~~~~~~~~~ - -An optional callable that tells the filter how to handle the queryset. It -recieves a ``QuerySet`` and the value to filter on and should return a -``Queryset`` that is filtered appropriately. `action` will default to -``filter_{value-of-name-attribute}`` - -``lookup_type`` -~~~~~~~~~~~~~~~ - -The type of lookup that should be performed using the [Django ORM](https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups "Django's ORM Lookups"). -All the normal options are allowed, and should be provided as a string. You can also -provide either ``None`` or a ``list`` or a ``tuple``. If ``None`` is provided, -then the user can select the lookup type from all the ones available in the Django -ORM. If a ``list`` or ``tuple`` is provided, then the user can select from those -options. - -``distinct`` -~~~~~~~~~~~~ - -A boolean value that specifies whether the Filter will use distinct on the -queryset. This option can be used to eliminate duplicate results when using filters that span related models. Defaults to ``False``. - -``exclude`` -~~~~~~~~~~~ - -A boolean value that specifies whether the Filter should use ``filter`` or ``exclude`` on the queryset. -Defaults to ``False``. - -ModelChoiceFilter and ModelMultipleChoiceFilter arguments --------------- - -These arguments apply specifically to ModelChoiceFiler and -ModelMultipleChoiceFilter only. - -``queryset`` -~~~~~~~~~~~ - -``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter`` require a queryset to operate on which must be passed as a kwarg. - - -``**kwargs`` -~~~~~~~~~~~~ - -Any extra keyword arguments will be provided to the accompanying form Field. -This can be used to provide arguments like ``choices``. diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt new file mode 100644 index 0000000..0967bf4 --- /dev/null +++ b/docs/ref/filterset.txt @@ -0,0 +1,164 @@ +FilterSet Guide +=============== + +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`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can allow the user to control ordering by providing the +``order_by`` argument in the Filter's Meta class. ``order_by`` can be either a +``list`` or ``tuple`` of field names, in which case those are the options, or +it can be a ``bool`` which, if True, indicates that all fields that +the user can filter on can also be sorted on. An example of ordering using a list:: + + import django_filters + + class ProductFilter(django_filters.FilterSet): + price = django_filters.NumberFilter(lookup_expr='lt') + class Meta: + model = Product + fields = ['price', 'release_date'] + order_by = ['price'] + +If you want to control the display of items in ``order_by``, you can set it to +a list or tuple of 2-tuples in the format ``(field_name, display_name)``. +This lets you override the displayed names for your ordering fields:: + + order_by = ( + ('name', 'Company Name'), + ('average_rating', 'Stars'), + ) + +Note that the default query parameter name used for ordering is ``o``. You +can override this by setting an ``order_by_field`` attribute on the +``FilterSet`` class to the string value you would like to use. + + +.. _form: + +Custom Forms using ``form`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The inner ``Meta`` class also takes an optional ``form`` argument. This is a +form class from which ``FilterSet.form`` will subclass. This works similar to +the ``form`` option on a ``ModelAdmin.`` + + +.. _together: + +Group fields with ``together`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The inner ``Meta`` class also takes an optional ``together`` argument. This +is a list of lists, each containing field names. For convenience can be a +single list/tuple when dealing with a single set of fields. Fields within a +field set must either be all or none present in the request for +``FilterSet.form`` to be valid:: + + import django_filters + + class ProductFilter(django_filters.FilterSet): + class Meta: + model = Product + fields = ['price', 'release_date', 'rating'] + together = ['rating', 'price'] + + +Non-Meta options +---------------- + +Note that these options do not go in the Meta class, they are specified directly +in your FilterSet class. + +- filter_overrides +- order_by_field +- :ref:`strict ` + + +.. _strict: + +``strict`` +~~~~~~~~~~ + +The ``strict`` option controls whether results are returned when an invalid +value is specified by the user for any filter field. By default, ``strict`` is +set to ``STRICTNESS.RETURN_NO_RESULTS`` meaning that an empty queryset is +returned if any field contains an invalid value. You can loosen this behavior +by setting ``strict`` to ``STRICTNESS.IGNORE`` which will effectively ignore a +filter field if its value is invalid. A third option of +``STRICTNESS.RAISE_VALIDATION_ERROR`` will cause a ``ValidationError`` to be +raised if any field contains an invalid value. + +Overriding ``FilterSet`` methods +-------------------------------- + +``filter_for_lookup()`` +~~~~~~~~~~~~~~~~~~~~~~~ + +Prior to version 0.13.0, filter generation did not take into account the +``lookup_expr`` used. This commonly caused malformed filters to be generated +for 'isnull', 'in', and 'range' lookups (as well as transformed lookups). The +current implementation provides the following behavior: + +- 'isnull' lookups return a ``BooleanFilter`` +- 'in' lookups return a filter derived from the CSV-based ``BaseInFilter``. +- 'range' lookups return a filter derived from the CSV-based ``BaseRangeFilter``. + +If you want to override the ``filter_class`` and ``params`` used to instantiate +filters for a model field, you can override ``filter_for_lookup()``. Ex:: + + class ProductFilter(django_filters.FilterSet): + class Meta: + model = Product + fields = { + 'release_date': ['exact', 'range'], + } + + @classmethod + def filter_for_lookup(cls, f, lookup_type): + # override date range lookups + if isinstance(f, models.DateField) and lookup_type == 'range': + return django_filters.DateRangeFiler, {} + + # use default behavior otherwise + return super(ProductFilter, cls).filter_for_lookup(f, lookup_type) + + +``get_ordering_field()`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to use a custom widget, or in any other way override the ordering +field you can override the ``get_ordering_field()`` method on a ``FilterSet``. +This method just needs to return a Form Field. + +Ordering on multiple fields, or other complex orderings can be achieved by +overriding the ``FilterSet.get_order_by()`` method. This is passed the selected +``order_by`` value, and is expected to return an iterable of values to pass to +``QuerySet.order_by``. For example, to sort a ``User`` table by last name, then +first name:: + + class UserFilter(django_filters.FilterSet): + class Meta: + order_by = ( + ('username', 'Username'), + ('last_name', 'Last Name') + ) + + def get_order_by(self, order_value): + if order_value == 'last_name': + return ['last_name', 'first_name'] + return super(UserFilter, self).get_order_by(order_value) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt new file mode 100644 index 0000000..760442d --- /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`` + +Enable / disable the default field ``help_text`` on filters where ``exclude=True`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. + +FILTERS_HELP_TEXT_FILTER +------------------------ + +Default: ``True`` + +Enable / disable the default field ``help_text`` on filters where ``exclude=False`` (:doc:`/ref/filters`). This does not affect fields which explicitly define ``help_text``. diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index 7acd017..4aabf1c 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -4,13 +4,16 @@ Widget Reference This is a reference document with a list of the provided widgets and their arguments. + +.. _link-widget: + ``LinkWidget`` ~~~~~~~~~~~~~~ This widget renders each option as a link, instead of an actual . 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. @@ -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. diff --git a/docs/usage.txt b/docs/usage.txt index 8b90448..10609fc 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,42 +37,109 @@ price or the release_date. We create a ``FilterSet`` for this:: As you can see this uses a very similar API to Django's ``ModelForm``. Just like with a ``ModelForm`` we can also override filters, or add new ones using a -declarative syntax:: +declarative syntax. + +Declaring filters +~~~~~~~~~~~~~~~~~ + +The declarative syntax provides you with the most flexibility when creating +filters, however it is fairly verbose. We'll use the below example to outline +the :ref:`core filter arguments ` on a ``FilterSet``:: + + class ProductFilter(django_filters.FilterSet): + price = django_filters.NumberFilter() + price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt') + price__lt = django_filters.NumberFilter(name='price', lookup_expr='lt') + + release_year = django_filters.NumberFilter(name='release_date', lookup_expr='year') + release_year__gt = django_filters.NumberFilter(name='release_date', lookup_expr='year__gt') + release_year__lt = django_filters.NumberFilter(name='release_date', lookup_expr='year__lt') + + manufacturer__name = django_filters.CharFilter(lookup_expr='icontains') + + class Meta: + model = Product + +There are two main arguments for filters: + +- ``name``: The name of the model field to filter on. You can traverse + "relationship paths" using Django's ``__`` syntax to filter fields on a + related model. ex, ``manufacturer__name``. +- ``lookup_expr``: The `field lookup`_ to use when filtering. Django's ``__`` + syntax can again be used in order to support lookup transforms. + ex, ``year__gte``. + +.. _`field lookup`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups + +Together, the field ``name`` and ``lookup_expr`` represent a complete Django +lookup expression. A detailed explanation of lookup expressions is provided in +Django's `lookup reference`_. django-filter supports expressions containing +both transforms and a final lookup for version 1.9 of Django and above. +For Django version 1.8, transformed expressions are not supported. + +.. _`lookup reference`: https://docs.djangoproject.com/en/dev/ref/models/lookups/#module-django.db.models.lookups + +While both of these fields are optional, it is recommended that you specify +them. By default, if ``name`` is not specified, the filter's name on the +filterset class will be used. Additionally, ``lookup_expr`` defaults to +``exact``. The following is an example of a misconfigured price filter:: + + class ProductFilter(django_filters.FilterSet): + price__gt = django_filters.NumberFilter() + +The filter instance will have a field name of ``price__gt`` and an ``exact`` +lookup type. Under the hood, this will incorrectly be resolved as:: + + Produce.objects.filter(price__gt__exact=value) + +.. note:: + + It's quite common to forget to set the lookup expression for + :code:`CharField` and :code:`TextField` and wonder why a search for "foo" + does not return results for "foobar". This is because the default lookup + type is `exact`, but you probably want to perform an `icontains` lookup. + +For more information on additional arguments, see :ref:`core-arguments`. + + +Generating filters with Meta.fields +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The FilterSet Meta class provides a ``fields`` attribute that can be used for +easily specifying multiple filters without significant code duplication. The +base syntax supports a list of multiple field names:: import django_filters class ProductFilter(django_filters.FilterSet): - price = django_filters.NumberFilter(lookup_type='lt') class Meta: model = Product fields = ['price', 'release_date'] -Filters take a ``lookup_type`` argument which specifies what lookup type to -use with `Django's ORM`_. So here when a user entered a price it would show all -Products with a price less than that. +The above generates 'exact' lookups for both the 'price' and 'release_date' +fields. -.. _`Django's ORM`: https://docs.djangoproject.com/en/dev/ref/models/querysets/#field-lookups - -**It's quite common to forget to set lookup type for `CharField`s/`TextField`s -and wonder why search for "foo" doesn't return result for "foobar". It's because -default lookup type is exact text, but you probably want `icontains` lookup -field.** - -The FilterSet Meta class fields can additionally be set using a Dictionary to -specify multiple ``lookup_type`` filters without significant code duplication:: +Additionally, a dictionary can be used to specify multiple lookup expressions +for each field:: import django_filters class ProductFilter(django_filters.FilterSet): class Meta: model = Product - fields = {'price': ['lt', 'gt'], - 'release_date': ['exact'], - } + fields = { + 'price': ['lt', 'gt'], + 'release_date': ['exact', 'year__gt'], + } -The above would generate 'price__lt', 'price__gt' and 'release_date' filters. -The filter lookup type keyword 'exact' is the default and therefore never added -to a filter name. +The above would generate 'price__lt', 'price__gt', 'release_date', and +'release_date__year__gt' filters. + +.. note:: + + The filter lookup type 'exact' is an implicit default and therefore never + added to a filter name. In the above example, the release date's exact + filter is 'release_date', not 'release_date__exact'. Items in the ``fields`` sequence in the ``Meta`` class may include "relationship paths" using Django's ``__`` syntax to filter on fields on a @@ -81,20 +150,9 @@ related model:: model = Product fields = ['manufacturer__country'] -Filters also take any arbitrary keyword arguments which get passed onto the -``django.forms.Field`` initializer. These extra keyword arguments get stored -in ``Filter.extra``, so it's possible to override the initializer of a -``FilterSet`` to add extra ones:: - class ProductFilter(django_filters.FilterSet): - class Meta: - model = Product - fields = ['manufacturer'] - - def __init__(self, *args, **kwargs): - super(ProductFilter, self).__init__(*args, **kwargs) - self.filters['manufacturer'].extra.update( - {'empty_label': 'All Manufacturers'}) +Overriding default filters +"""""""""""""""""""""""""" Like ``django.contrib.admin.ModelAdmin``, it is possible to override default filters for all the models fields of the same kind using @@ -105,18 +163,28 @@ default filters for all the models fields of the same kind using models.CharField: { 'filter_class': django_filters.CharFilter, 'extra': lambda f: { - 'lookup_type': 'icontains', - } - } + 'lookup_expr': 'icontains', + }, + }, + models.BooleanField: { + 'filter_class': django_filters.BooleanFilter, + 'extra': lambda f: { + 'widget': 'forms.CheckboxInput', + }, + }, } class Meta: model = Product - fields = ['name'] + fields = { + 'name': ['exact'], + 'release_date': ['isnull'], + } -MethodFilter -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Custom filtering with MethodFilter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + If you want fine control over each individual filter attribute, you can use the ``MethodFilter`` filter. @@ -188,7 +256,7 @@ Now we need to write a view:: def product_list(request): f = ProductFilter(request.GET, queryset=Product.objects.all()) - return render_to_response('my_app/template.html', {'filter': f}) + return render(request, 'my_app/template.html', {'filter': f}) If a queryset argument isn't provided then all the items in the default manager of the model will be used. @@ -196,6 +264,7 @@ of the model will be used. If you want to access the filtered objects in your views, for example if you want to paginate them, you can do that. They are in f.qs + The URL conf ------------ @@ -203,6 +272,7 @@ We need a URL pattern to call the view:: url(r'^list$', views.product_list) + The template ------------ @@ -224,113 +294,9 @@ And that's all there is to it! The ``form`` attribute contains a normal Django form, and when we iterate over the ``FilterSet`` we get the objects in the resulting queryset. -Other Meta options ------------------- -Ordering using ``order_by`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can allow the user to control ordering by providing the -``order_by`` argument in the Filter's Meta class. ``order_by`` can be either a -``list`` or ``tuple`` of field names, in which case those are the options, or -it can be a ``bool`` which, if True, indicates that all fields that -the user can filter on can also be sorted on. An example or ordering using a list:: - - import django_filters - - class ProductFilter(django_filters.FilterSet): - price = django_filters.NumberFilter(lookup_type='lt') - class Meta: - model = Product - fields = ['price', 'release_date'] - order_by = ['price'] - -If you want to control the display of items in ``order_by``, you can set it to -a list or tuple of 2-tuples in the format ``(field_name, display_name)``. -This lets you override the displayed names for your ordering fields:: - - order_by = ( - ('name', 'Company Name'), - ('average_rating', 'Stars'), - ) - -Note that the default query parameter name used for ordering is ``o``. You -can override this by setting an ``order_by_field`` attribute on the -``FilterSet`` class to the string value you would like to use. - -Custom Forms using ``form`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The inner ``Meta`` class also takes an optional ``form`` argument. This is a -form class from which ``FilterSet.form`` will subclass. This works similar to -the ``form`` option on a ``ModelAdmin.`` - -Group fields with ``together`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The inner ``Meta`` class also takes an optional ``together`` argument. This -is a list of lists, each containing field names. For convenience can be a -single list/tuple when dealing with a single set of fields. Fields within a -field set must either be all or none present in the request for -``FilterSet.form`` to be valid:: - - import django_filters - - class ProductFilter(django_filters.FilterSet): - class Meta: - model = Product - fields = ['price', 'release_date', 'rating'] - together = ['rating', 'price'] - - -Non-Meta options ----------------- - -Note that these options do not go in the Meta class, they are specified directly -in your FilterSet class. - -``strict`` -~~~~~~~~~~ - -The ``strict`` option controls whether results are returned when an invalid -value is specified by the user for any filter field. By default, ``strict`` is -set to ``STRICTNESS.RETURN_NO_RESULTS`` meaning that an empty queryset is -returned if any field contains an invalid value. You can loosen this behavior -by setting ``strict`` to ``STRICTNESS.IGNORE`` which will effectively ignore a -filter field if its value is invalid. A third option of -``STRICTNESS.RAISE_VALIDATION_ERROR`` will cause a ``ValidationError` to be -raised if any field contains an invalid value. - -Overriding ``FilterSet`` methods --------------------------------- - -``get_ordering_field()`` -~~~~~~~~~~~~~~~~~~~~~~~~ - -If you want to use a custom widget, or in any other way override the ordering -field you can override the ``get_ordering_field()`` method on a ``FilterSet``. -This method just needs to return a Form Field. - -Ordering on multiple fields, or other complex orderings can be achieved by -overriding the ``FilterSet.get_order_by()`` method. This is passed the selected -``order_by`` value, and is expected to return an iterable of values to pass to -``QuerySet.order_by``. For example, to sort a ``User`` table by last name, then -first name:: - - class UserFilter(django_filters.FilterSet): - class Meta: - order_by = ( - ('username', 'Username'), - ('last_name', 'Last Name') - ) - - def get_order_by(self, order_value): - if order_value == 'last_name': - return ['last_name', 'first_name'] - return super(UserFilter, self).get_order_by(order_value) - -Generic View ------------- +Generic view & configuration +----------------------------- In addition to the above usage there is also a class-based generic view included in django-filter, which lives at ``django_filters.views.FilterView``. @@ -365,33 +331,3 @@ to it as the class based view:: The needed template and its context variables will also be the same as the class-based view above. - -Custom lookup types -------------------- - -By default django-filter uses Django's ORM built-in field lookups. If you want to globally accept specific lookups you can do the following: - - from django_filters import filters - - filters.LOOKUP_TYPES = ['gt', 'gte', 'lt', 'lte', 'custom_lookup_type'] - -Choices help text ------------------ - -If you want the ``ChoiceField`` created from `LOOKUP_TYPES` to have human-friendly options you can do the following: - - from django_filters import filters - - filters.LOOKUP_TYPES = [ - ('', '---------'), - ('exact', 'Is equal to'), - ('not_exact', 'Is not equal to'), - ('lt', 'Lesser than'), - ('gt', 'Greater than'), - ('gte', 'Greater than or equal to'), - ('lte', 'Lesser than or equal to'), - ('startswith', 'Starts with'), - ('endswith', 'Ends with'), - ('contains', 'Contains'), - ('not_contains', 'Does not contain'), - ] 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': [ 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): diff --git a/tests/test_fields.py b/tests/test_fields.py index f7809ee..1b1d6b4 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -8,17 +8,33 @@ import unittest import django from django import forms from django.test import TestCase, override_settings -from django.utils.timezone import make_aware +from django.utils.timezone import make_aware, get_default_timezone from django_filters.widgets import RangeWidget from django_filters.fields import ( - RangeField, LookupTypeField, Lookup, DateRangeField, TimeRangeField, IsoDateTimeField) + Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField, + DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField +) def to_d(float_value): return decimal.Decimal('%.2f' % float_value) +class LookupBoolTests(TestCase): + def test_lookup_true(self): + self.assertTrue(Lookup(True, 'exact')) + self.assertTrue(Lookup(1, 'exact')) + self.assertTrue(Lookup('1', 'exact')) + self.assertTrue(Lookup(datetime.now(), 'exact')) + + def test_lookup_false(self): + self.assertFalse(Lookup(False, 'exact')) + self.assertFalse(Lookup(0, 'exact')) + self.assertFalse(Lookup('', 'exact')) + self.assertFalse(Lookup(None, 'exact')) + + class RangeFieldTests(TestCase): def test_field(self): @@ -40,16 +56,32 @@ class DateRangeFieldTests(TestCase): f = DateRangeField() self.assertEqual(len(f.fields), 2) + @override_settings(USE_TZ=False) def test_clean(self): w = RangeWidget() f = DateRangeField(widget=w) - self.assertEqual( f.clean(['2015-01-01', '2015-01-10']), - slice(datetime(2015, 1, 1, 0, 0 , 0), + slice(datetime(2015, 1, 1, 0, 0, 0), datetime(2015, 1, 10, 23, 59, 59, 999999))) +class DateTimeRangeFieldTests(TestCase): + + def test_field(self): + f = DateTimeRangeField() + self.assertEqual(len(f.fields), 2) + + @override_settings(USE_TZ=False) + def test_clean(self): + w = RangeWidget() + f = DateTimeRangeField(widget=w) + self.assertEqual( + f.clean(['2015-01-01 10:30', '2015-01-10 8:45']), + slice(datetime(2015, 1, 1, 10, 30, 0), + datetime(2015, 1, 10, 8, 45, 0))) + + class TimeRangeFieldTests(TestCase): def test_field(self): @@ -138,7 +170,7 @@ class IsoDateTimeFieldTests(TestCase): def test_datetime_timezone_awareness(self): # parsed datetimes should obey USE_TZ f = IsoDateTimeField() - r = make_aware(self.reference_dt, f.default_timezone) + r = make_aware(self.reference_dt, get_default_timezone()) d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601) self.assertTrue(isinstance(d.tzinfo, tzinfo)) @@ -154,10 +186,6 @@ class IsoDateTimeFieldTests(TestCase): f = IsoDateTimeField() r = self.reference_dt.replace() - # It's necessary to override this here, since the field class is parsed - # when USE_TZ = True. - f.default_timezone = None - d = f.strptime(self.reference_str + "+01:00", IsoDateTimeField.ISO_8601) self.assertTrue(d.tzinfo is None) self.assertEqual(d, r - timedelta(hours=1)) @@ -165,3 +193,50 @@ class IsoDateTimeFieldTests(TestCase): d = f.strptime(self.reference_str + "", IsoDateTimeField.ISO_8601) self.assertTrue(d.tzinfo is None) self.assertEqual(d, r) + + +class BaseCSVFieldTests(TestCase): + def setUp(self): + class DecimalCSVField(BaseCSVField, forms.DecimalField): + pass + + self.field = DecimalCSVField() + + def test_clean(self): + self.assertEqual(self.field.clean(None), None) + self.assertEqual(self.field.clean(''), []) + self.assertEqual(self.field.clean(['1']), [1]) + self.assertEqual(self.field.clean(['1', '2']), [1, 2]) + self.assertEqual(self.field.clean(['1', '2', '3']), [1, 2, 3]) + + def test_validation_error(self): + with self.assertRaises(forms.ValidationError): + self.field.clean(['']) + + with self.assertRaises(forms.ValidationError): + self.field.clean(['a', 'b', 'c']) + + +class BaseRangeFieldTests(TestCase): + def setUp(self): + class DecimalRangeField(BaseRangeField, forms.DecimalField): + pass + + self.field = DecimalRangeField() + + def test_clean(self): + self.assertEqual(self.field.clean(None), None) + self.assertEqual(self.field.clean(['1', '2']), [1, 2]) + + def test_validation_error(self): + with self.assertRaises(forms.ValidationError): + self.field.clean('') + + with self.assertRaises(forms.ValidationError): + self.field.clean(['']) + + with self.assertRaises(forms.ValidationError): + self.field.clean(['1']) + + with self.assertRaises(forms.ValidationError): + self.field.clean(['1', '2', '3']) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 6b09ec6..79c026e 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -5,22 +5,27 @@ import datetime import mock import unittest -from django.test import TestCase +import django +from django.test import TestCase, override_settings from django.utils import six from django.utils.timezone import now from django.utils import timezone from django_filters.filterset import FilterSet from django_filters.filters import AllValuesFilter +from django_filters.filters import BaseInFilter from django_filters.filters import CharFilter from django_filters.filters import ChoiceFilter from django_filters.filters import DateRangeFilter +from django_filters.filters import DateFromToRangeFilter +from django_filters.filters import DateTimeFromToRangeFilter # from django_filters.filters import DateTimeFilter from django_filters.filters import MethodFilter from django_filters.filters import MultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import NumberFilter from django_filters.filters import RangeFilter +from django_filters.filters import TimeRangeFilter # from django_filters.widgets import LinkWidget from .models import User @@ -446,9 +451,9 @@ class NumberFilterTests(TestCase): f = F({'price': 10}, queryset=Book.objects.all()) self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title) - def test_filtering_with_single_lookup_type(self): + def test_filtering_with_single_lookup_expr(self): class F(FilterSet): - price = NumberFilter(lookup_type='lt') + price = NumberFilter(lookup_expr='lt') class Meta: model = Book @@ -458,7 +463,7 @@ class NumberFilterTests(TestCase): self.assertQuerysetEqual( f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title) - def test_filtering_with_single_lookup_type_dictionary(self): + def test_filtering_with_single_lookup_expr_dictionary(self): class F(FilterSet): class Meta: model = Book @@ -468,9 +473,9 @@ class NumberFilterTests(TestCase): self.assertQuerysetEqual( f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title) - def test_filtering_with_multiple_lookup_types(self): + def test_filtering_with_multiple_lookup_exprs(self): class F(FilterSet): - price = NumberFilter(lookup_type=['lt', 'gt']) + price = NumberFilter(lookup_expr=['lt', 'gt']) class Meta: model = Book @@ -487,7 +492,7 @@ class NumberFilterTests(TestCase): lambda o: o.title, ordered=False) class F(FilterSet): - price = NumberFilter(lookup_type=['lt', 'gt', 'exact']) + price = NumberFilter(lookup_expr=['lt', 'gt', 'exact']) class Meta: model = Book @@ -619,6 +624,102 @@ class DateRangeFilterTests(TestCase): # different things to both user and server. +class DateFromToRangeFilterTests(TestCase): + + def test_filtering(self): + adam = User.objects.create(username='adam') + kwargs = {'text': 'test', 'author': adam, 'time': '10:00'} + Comment.objects.create(date=datetime.date(2016, 1, 1), **kwargs) + Comment.objects.create(date=datetime.date(2016, 1, 2), **kwargs) + Comment.objects.create(date=datetime.date(2016, 1, 3), **kwargs) + Comment.objects.create(date=datetime.date(2016, 1, 3), **kwargs) + + class F(FilterSet): + published = DateFromToRangeFilter(name='date') + + class Meta: + model = Comment + fields = ['date'] + + results = F(data={ + 'published_0': '2016-01-02', + 'published_1': '2016-01-03'}) + self.assertEqual(len(results.qs), 3) + + def test_filtering_ignores_time(self): + tz = timezone.get_current_timezone() + Article.objects.create( + published=datetime.datetime(2016, 1, 1, 10, 0, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 2, 12, 45, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 3, 18, 15, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 3, 19, 30, tzinfo=tz)) + + class F(FilterSet): + published = DateFromToRangeFilter() + + class Meta: + model = Article + fields = ['published'] + + results = F(data={ + 'published_0': '2016-01-02', + 'published_1': '2016-01-03'}) + self.assertEqual(len(results.qs), 3) + + +class DateTimeFromToRangeFilterTests(TestCase): + + def test_filtering(self): + tz = timezone.get_current_timezone() + Article.objects.create( + published=datetime.datetime(2016, 1, 1, 10, 0, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 2, 12, 45, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 3, 18, 15, tzinfo=tz)) + Article.objects.create( + published=datetime.datetime(2016, 1, 3, 19, 30, tzinfo=tz)) + + class F(FilterSet): + published = DateTimeFromToRangeFilter() + + class Meta: + model = Article + fields = ['published'] + + results = F(data={ + 'published_0': '2016-01-02 10:00', + 'published_1': '2016-01-03 19:00'}) + self.assertEqual(len(results.qs), 2) + + +class TimeRangeFilterTests(TestCase): + + def test_filtering(self): + adam = User.objects.create(username='adam') + kwargs = { + 'text': 'test', 'author': adam, 'date': datetime.date.today()} + Comment.objects.create(time='7:30', **kwargs) + Comment.objects.create(time='8:00', **kwargs) + Comment.objects.create(time='9:30', **kwargs) + Comment.objects.create(time='11:00', **kwargs) + + class F(FilterSet): + time = TimeRangeFilter() + + class Meta: + model = Comment + fields = ['time'] + + results = F(data={ + 'time_0': '8:00', + 'time_1': '10:00'}) + self.assertEqual(len(results.qs), 2) + + class AllValuesFilterTests(TestCase): def test_filtering(self): @@ -1240,6 +1341,189 @@ class NonSymmetricalSelfReferentialRelationshipTests(TestCase): self.assertQuerysetEqual(f.qs, [2], lambda o: o.pk) +# use naive datetimes, as pytz is required to perform +# date lookups when timezones are involved. +@override_settings(USE_TZ=False) +@unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") +class TransformedQueryExpressionFilterTests(TestCase): + + def test_filtering(self): + now_dt = datetime.datetime.now() + after_5pm = now_dt.replace(hour=18) + before_5pm = now_dt.replace(hour=16) + + u = User.objects.create(username='alex') + a = Article.objects.create(author=u, published=after_5pm) + Article.objects.create(author=u, published=before_5pm) + + class F(FilterSet): + class Meta: + model = Article + fields = {'published': ['hour__gte']} + + qs = Article.objects.all() + f = F({'published__hour__gte': 17}, queryset=qs) + self.assertEqual(len(f.qs), 1) + self.assertQuerysetEqual(f.qs, [a.pk], lambda o: o.pk) + + +# use naive datetimes, as pytz is required to perform +# date lookups when timezones are involved. +@override_settings(USE_TZ=False) +class CSVFilterTests(TestCase): + + def setUp(self): + u1 = User.objects.create(username='alex', status=1) + u2 = User.objects.create(username='jacob', status=2) + User.objects.create(username='aaron', status=2) + User.objects.create(username='carl', status=0) + + now_dt = datetime.datetime.now() + after_5pm = now_dt.replace(hour=18) + before_5pm = now_dt.replace(hour=16) + + Article.objects.create(author=u1, published=after_5pm) + Article.objects.create(author=u2, published=after_5pm) + Article.objects.create(author=u1, published=before_5pm) + Article.objects.create(author=u2, published=before_5pm) + + class UserFilter(FilterSet): + class Meta: + model = User + fields = { + 'username': ['in'], + 'status': ['in'], + } + + class ArticleFilter(FilterSet): + class Meta: + model = Article + fields = { + 'author': ['in'], + 'published': ['in'], + } + + self.user_filter = UserFilter + self.article_filter = ArticleFilter + + self.after_5pm = after_5pm.strftime('%Y-%m-%d %H:%M:%S.%f') + self.before_5pm = before_5pm.strftime('%Y-%m-%d %H:%M:%S.%f') + + def test_numeric_filtering(self): + F = self.user_filter + + qs = User.objects.all() + f = F(queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'status__in': ''}, queryset=qs) + self.assertEqual(len(f.qs), 0) + self.assertEqual(f.count(), 0) + + f = F({'status__in': '0'}, queryset=qs) + self.assertEqual(len(f.qs), 1) + self.assertEqual(f.count(), 1) + + f = F({'status__in': '0,2'}, queryset=qs) + self.assertEqual(len(f.qs), 3) + self.assertEqual(f.count(), 3) + + f = F({'status__in': '0,,1'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'status__in': '2'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + def test_string_filtering(self): + F = self.user_filter + + qs = User.objects.all() + f = F(queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'username__in': ''}, queryset=qs) + self.assertEqual(len(f.qs), 0) + self.assertEqual(f.count(), 0) + + f = F({'username__in': 'alex'}, queryset=qs) + self.assertEqual(len(f.qs), 1) + self.assertEqual(f.count(), 1) + + f = F({'username__in': 'alex,aaron'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'username__in': 'alex,,aaron'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'username__in': 'alex,'}, queryset=qs) + self.assertEqual(len(f.qs), 1) + self.assertEqual(f.count(), 1) + + def test_datetime_filtering(self): + F = self.article_filter + after = self.after_5pm + before = self.before_5pm + + qs = Article.objects.all() + f = F(queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'published__in': ''}, queryset=qs) + self.assertEqual(len(f.qs), 0) + self.assertEqual(f.count(), 0) + + f = F({'published__in': '%s' % (after, )}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'published__in': '%s,%s' % (after, before, )}, queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'published__in': '%s,,%s' % (after, before, )}, queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'published__in': '%s,' % (after, )}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + def test_related_filtering(self): + F = self.article_filter + + qs = Article.objects.all() + f = F(queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'author__in': ''}, queryset=qs) + self.assertEqual(len(f.qs), 0) + self.assertEqual(f.count(), 0) + + f = F({'author__in': '1'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + f = F({'author__in': '1,2'}, queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'author__in': '1,,2'}, queryset=qs) + self.assertEqual(len(f.qs), 4) + self.assertEqual(f.count(), 4) + + f = F({'author__in': '1,'}, queryset=qs) + self.assertEqual(len(f.qs), 2) + self.assertEqual(f.count(), 2) + + class MiscFilterSetTests(TestCase): def setUp(self): diff --git a/tests/test_filters.py b/tests/test_filters.py index cdcf625..6b2db80 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,21 +1,23 @@ from __future__ import absolute_import from __future__ import unicode_literals -from datetime import date, time, timedelta +from datetime import date, time, timedelta, datetime import mock +import warnings import unittest -import django from django import forms -from django.test import TestCase +from django.test import TestCase, override_settings from django_filters import filters from django_filters.fields import ( Lookup, RangeField, DateRangeField, + DateTimeRangeField, TimeRangeField, - LookupTypeField) + LookupTypeField, + BaseCSVField) from django_filters.filters import ( Filter, CharFilter, @@ -32,8 +34,12 @@ from django_filters.filters import ( RangeFilter, DateRangeFilter, DateFromToRangeFilter, + DateTimeFromToRangeFilter, TimeRangeFilter, AllValuesFilter, + BaseCSVFilter, + BaseInFilter, + BaseRangeFilter, UUIDFilter, LOOKUP_TYPES) @@ -44,7 +50,7 @@ class FilterTests(TestCase): def test_creation(self): f = Filter() - self.assertEqual(f.lookup_type, 'exact') + self.assertEqual(f.lookup_expr, 'exact') self.assertEqual(f.exclude, False) def test_creation_order(self): @@ -64,26 +70,40 @@ class FilterTests(TestCase): self.assertIsInstance(field, forms.Field) self.assertEqual(field.help_text, 'This is an exclusion filter') - def test_field_with_single_lookup_type(self): - f = Filter(lookup_type='iexact') + @override_settings(FILTERS_HELP_TEXT_FILTER=False) + def test_default_field_settings(self): + f = Filter() + field = f.field + self.assertIsInstance(field, forms.Field) + self.assertEqual(field.help_text, '') + + @override_settings(FILTERS_HELP_TEXT_EXCLUDE=False) + def test_field_with_exclusion_settings(self): + f = Filter(exclude=True) + field = f.field + self.assertIsInstance(field, forms.Field) + self.assertEqual(field.help_text, '') + + def test_field_with_single_lookup_expr(self): + f = Filter(lookup_expr='iexact') field = f.field self.assertIsInstance(field, forms.Field) - def test_field_with_none_lookup_type(self): - f = Filter(lookup_type=None) + def test_field_with_none_lookup_expr(self): + f = Filter(lookup_expr=None) field = f.field self.assertIsInstance(field, LookupTypeField) choice_field = field.fields[1] self.assertEqual(len(choice_field.choices), len(LOOKUP_TYPES)) - def test_field_with_lookup_type_and_exlusion(self): - f = Filter(lookup_type=None, exclude=True) + def test_field_with_lookup_expr_and_exlusion(self): + f = Filter(lookup_expr=None, exclude=True) field = f.field self.assertIsInstance(field, LookupTypeField) self.assertEqual(field.help_text, 'This is an exclusion filter') - def test_field_with_list_lookup_type(self): - f = Filter(lookup_type=('istartswith', 'iendswith')) + def test_field_with_list_lookup_expr(self): + f = Filter(lookup_expr=('istartswith', 'iendswith')) field = f.field self.assertIsInstance(field, LookupTypeField) choice_field = field.fields[1] @@ -152,22 +172,22 @@ class FilterTests(TestCase): def test_filtering_with_list_value(self): qs = mock.Mock(spec=['filter']) - f = Filter(name='somefield', lookup_type=['some_lookup_type']) - result = f.filter(qs, Lookup('value', 'some_lookup_type')) - qs.filter.assert_called_once_with(somefield__some_lookup_type='value') + f = Filter(name='somefield', lookup_expr=['some_lookup_expr']) + result = f.filter(qs, Lookup('value', 'some_lookup_expr')) + qs.filter.assert_called_once_with(somefield__some_lookup_expr='value') self.assertNotEqual(qs, result) def test_filtering_skipped_with_list_value_with_blank(self): qs = mock.Mock() - f = Filter(name='somefield', lookup_type=['some_lookup_type']) - result = f.filter(qs, Lookup('', 'some_lookup_type')) + f = Filter(name='somefield', lookup_expr=['some_lookup_expr']) + result = f.filter(qs, Lookup('', 'some_lookup_expr')) self.assertListEqual(qs.method_calls, []) self.assertEqual(qs, result) def test_filtering_skipped_with_list_value_with_blank_lookup(self): - return # Now field is required to provide valid lookup_type if it provides any + return # Now field is required to provide valid lookup_expr if it provides any qs = mock.Mock(spec=['filter']) - f = Filter(name='somefield', lookup_type=None) + f = Filter(name='somefield', lookup_expr=None) result = f.filter(qs, Lookup('value', '')) qs.filter.assert_called_once_with(somefield__exact='value') self.assertNotEqual(qs, result) @@ -187,6 +207,43 @@ class FilterTests(TestCase): result = qs.distinct.assert_called_once_with() self.assertNotEqual(qs, result) + def test_lookup_type_deprecation(self): + """ + Make sure user is alerted when using deprecated ``lookup_type``. + """ + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + Filter(lookup_type='exact') + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + +class CustomFilterWithBooleanCheckTests(TestCase): + + def setUp(self): + super(CustomFilterWithBooleanCheckTests, self).setUp() + + class CustomTestFilter(Filter): + def filter(self_, qs, value): + if not value: + return qs + return super(CustomTestFilter, self_).filter(qs, value) + + self.test_filter_class = CustomTestFilter + + def test_lookup_false(self): + qs = mock.Mock(spec=['filter']) + f = self.test_filter_class(name='somefield') + result = f.filter(qs, Lookup('', 'exact')) + self.assertEqual(qs, result) + + def test_lookup_true(self): + qs = mock.Mock(spec=['filter']) + f = self.test_filter_class(name='somefield') + result = f.filter(qs, Lookup('somesearch', 'exact')) + qs.filter.assert_called_once_with(somefield__exact='somesearch') + self.assertNotEqual(qs, result) + class CharFilterTests(TestCase): @@ -240,9 +297,9 @@ class BooleanFilterTests(TestCase): self.assertListEqual(qs.method_calls, []) self.assertEqual(qs, result) - def test_filtering_lookup_type(self): + def test_filtering_lookup_expr(self): qs = mock.Mock(spec=['filter']) - f = BooleanFilter(name='somefield', lookup_type='isnull') + f = BooleanFilter(name='somefield', lookup_expr='isnull') result = f.filter(qs, True) qs.filter.assert_called_once_with(somefield__isnull=True) self.assertNotEqual(qs, result) @@ -503,10 +560,10 @@ class NumericRangeFilterTests(TestCase): result = f.filter(qs, None) self.assertEqual(qs, result) - def test_field_with_lookup_type(self): + def test_field_with_lookup_expr(self): qs = mock.Mock() value = mock.Mock(start=20, stop=30) - f = NumericRangeFilter(lookup_type=('overlap')) + f = NumericRangeFilter(lookup_expr=('overlap')) f.filter(qs, value) qs.filter.assert_called_once_with(None__overlap=(20, 30)) @@ -567,10 +624,10 @@ class RangeFilterTests(TestCase): result = f.filter(qs, None) self.assertEqual(qs, result) - def test_filtering_ignores_lookup_type(self): + def test_filtering_ignores_lookup_expr(self): qs = mock.Mock() value = mock.Mock(start=20, stop=30) - f = RangeFilter(lookup_type='gte') + f = RangeFilter(lookup_expr='gte') f.filter(qs, value) qs.filter.assert_called_once_with(None__range=(20, 30)) @@ -700,15 +757,61 @@ class DateFromToRangeFilterTests(TestCase): result = f.filter(qs, None) self.assertEqual(qs, result) - def test_filtering_ignores_lookup_type(self): + def test_filtering_ignores_lookup_expr(self): qs = mock.Mock() value = mock.Mock(start=date(2015, 4, 7), stop=date(2015, 9, 6)) - f = DateFromToRangeFilter(lookup_type='gte') + f = DateFromToRangeFilter(lookup_expr='gte') f.filter(qs, value) qs.filter.assert_called_once_with( None__range=(date(2015, 4, 7), date(2015, 9, 6))) +class DateTimeFromToRangeFilterTests(TestCase): + + def test_default_field(self): + f = DateTimeFromToRangeFilter() + field = f.field + self.assertIsInstance(field, DateTimeRangeField) + + def test_filtering_range(self): + qs = mock.Mock(spec=['filter']) + value = mock.Mock( + start=datetime(2015, 4, 7, 8, 30), stop=datetime(2015, 9, 6, 11, 45)) + f = DateTimeFromToRangeFilter() + f.filter(qs, value) + qs.filter.assert_called_once_with( + None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45))) + + def test_filtering_start(self): + qs = mock.Mock(spec=['filter']) + value = mock.Mock(start=datetime(2015, 4, 7, 8, 30), stop=None) + f = DateTimeFromToRangeFilter() + f.filter(qs, value) + qs.filter.assert_called_once_with(None__gte=datetime(2015, 4, 7, 8, 30)) + + def test_filtering_stop(self): + qs = mock.Mock(spec=['filter']) + value = mock.Mock(start=None, stop=datetime(2015, 9, 6, 11, 45)) + f = DateTimeFromToRangeFilter() + f.filter(qs, value) + qs.filter.assert_called_once_with(None__lte=datetime(2015, 9, 6, 11, 45)) + + def test_filtering_skipped_with_none_value(self): + qs = mock.Mock(spec=['filter']) + f = DateTimeFromToRangeFilter() + result = f.filter(qs, None) + self.assertEqual(qs, result) + + def test_filtering_ignores_lookup_type(self): + qs = mock.Mock() + value = mock.Mock( + start=datetime(2015, 4, 7, 8, 30), stop=datetime(2015, 9, 6, 11, 45)) + f = DateTimeFromToRangeFilter(lookup_type='gte') + f.filter(qs, value) + qs.filter.assert_called_once_with( + None__range=(datetime(2015, 4, 7, 8, 30), datetime(2015, 9, 6, 11, 45))) + + class TimeRangeFilterTests(TestCase): def test_default_field(self): @@ -744,10 +847,10 @@ class TimeRangeFilterTests(TestCase): result = f.filter(qs, None) self.assertEqual(qs, result) - def test_filtering_ignores_lookup_type(self): + def test_filtering_ignores_lookup_expr(self): qs = mock.Mock() value = mock.Mock(start=time(10, 15), stop=time(12, 30)) - f = TimeRangeFilter(lookup_type='gte') + f = TimeRangeFilter(lookup_expr='gte') f.filter(qs, value) qs.filter.assert_called_once_with( None__range=(time(10, 15), time(12, 30))) @@ -772,7 +875,7 @@ class AllValuesFilterTests(TestCase): class LookupTypesTests(TestCase): - def test_custom_lookup_types(self): + def test_custom_lookup_exprs(self): filters.LOOKUP_TYPES = [ ('', '---------'), ('exact', 'Is equal to'), @@ -787,12 +890,97 @@ class LookupTypesTests(TestCase): ('not_contains', 'Does not contain'), ] - f = Filter(lookup_type=None) + f = Filter(lookup_expr=None) field = f.field choice_field = field.fields[1] - choices = choice_field.choices + all_choices = choice_field.choices self.assertIsInstance(field, LookupTypeField) - self.assertEqual(choices, filters.LOOKUP_TYPES) - self.assertEqual(choices[1][0], 'exact') - self.assertEqual(choices[1][1], 'Is equal to') + self.assertEqual(all_choices, filters.LOOKUP_TYPES) + self.assertEqual(all_choices[1][0], 'exact') + self.assertEqual(all_choices[1][1], 'Is equal to') + + custom_f = Filter(lookup_expr=('endswith', 'not_contains')) + custom_field = custom_f.field + custom_choice_field = custom_field.fields[1] + my_custom_choices = custom_choice_field.choices + + available_lookup_exprs = [ + ('endswith', 'Ends with'), + ('not_contains', 'Does not contain'), + ] + + self.assertIsInstance(custom_field, LookupTypeField) + self.assertEqual(my_custom_choices, available_lookup_exprs) + self.assertEqual(my_custom_choices[0][0], 'endswith') + self.assertEqual(my_custom_choices[0][1], 'Ends with') + self.assertEqual(my_custom_choices[1][0], 'not_contains') + self.assertEqual(my_custom_choices[1][1], 'Does not contain') + + +class CSVFilterTests(TestCase): + def setUp(self): + class NumberInFilter(BaseCSVFilter, NumberFilter): + pass + + class DateTimeYearInFilter(BaseCSVFilter, DateTimeFilter): + pass + + self.number_in = NumberInFilter(lookup_expr='in') + self.datetimeyear_in = DateTimeYearInFilter(lookup_expr='year__in') + + def test_default_field(self): + f = BaseCSVFilter() + field = f.field + self.assertIsInstance(field, forms.Field) + + def test_concrete_field(self): + field = self.number_in.field + self.assertIsInstance(field, forms.DecimalField) + self.assertIsInstance(field, BaseCSVField) + self.assertEqual(field.__class__.__name__, 'DecimalInField') + + field = self.datetimeyear_in.field + self.assertIsInstance(field, forms.DateTimeField) + self.assertIsInstance(field, BaseCSVField) + self.assertEqual(field.__class__.__name__, 'DateTimeYearInField') + + def test_filtering(self): + qs = mock.Mock(spec=['filter']) + f = self.number_in + f.filter(qs, [1, 2]) + qs.filter.assert_called_once_with(None__in=[1, 2]) + + def test_filtering_skipped_with_none_value(self): + qs = mock.Mock(spec=['filter']) + f = self.number_in + result = f.filter(qs, None) + self.assertEqual(qs, result) + + def test_field_with_lookup_expr(self): + qs = mock.Mock() + f = self.datetimeyear_in + f.filter(qs, [1, 2]) + qs.filter.assert_called_once_with(None__year__in=[1, 2]) + + +class BaseInFilterTests(TestCase): + def test_filtering(self): + class NumberInFilter(BaseInFilter, NumberFilter): + pass + + qs = mock.Mock(spec=['filter']) + f = NumberInFilter() + f.filter(qs, [1, 2]) + qs.filter.assert_called_once_with(None__in=[1, 2]) + + +class BaseRangeFilterTests(TestCase): + def test_filtering(self): + class NumberInFilter(BaseRangeFilter, NumberFilter): + pass + + qs = mock.Mock(spec=['filter']) + f = NumberInFilter() + f.filter(qs, [1, 2]) + qs.filter.assert_called_once_with(None__range=[1, 2]) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index e8a6ff2..8876075 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -4,21 +4,28 @@ import mock import unittest import django +from django.core.exceptions import ValidationError from django.db import models from django.test import TestCase from django_filters.filterset import FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS -from django_filters.filterset import get_model_field +from django_filters.filterset import STRICTNESS +from django_filters.filters import BooleanFilter from django_filters.filters import CharFilter from django_filters.filters import NumberFilter from django_filters.filters import ChoiceFilter from django_filters.filters import ModelChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import UUIDFilter +from django_filters.filters import BaseInFilter +from django_filters.filters import BaseRangeFilter + +from django_filters.widgets import BooleanWidget from .models import User from .models import AdminUser +from .models import Article from .models import Book from .models import Profile from .models import Comment @@ -48,14 +55,6 @@ class HelperMethodsTests(TestCase): def test_get_declared_filters(self): pass - def test_get_model_field_none(self): - result = get_model_field(User, 'unknown__name') - self.assertIsNone(result) - - def test_get_model_field(self): - result = get_model_field(Business, 'hiredworker__worker') - self.assertEqual(result, HiredWorker._meta.get_field('worker')) - @unittest.skip('todo') def test_filters_for_model(self): pass @@ -180,11 +179,57 @@ class FilterSetFilterForFieldTests(TestCase): self.assertIsNotNone(result.extra['queryset']) self.assertEqual(result.extra['queryset'].model, Worker) + @unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") + def test_transformed_lookup_expr(self): + f = Comment._meta.get_field('date') + result = FilterSet.filter_for_field(f, 'date', 'year__gte') + self.assertIsInstance(result, NumberFilter) + self.assertEqual(result.name, 'date') + @unittest.skip('todo') def test_filter_overrides(self): pass +class FilterSetFilterForLookupTests(TestCase): + + def test_filter_for_ISNULL_lookup(self): + f = Article._meta.get_field('author') + result, params = FilterSet.filter_for_lookup(f, 'isnull') + self.assertEqual(result, BooleanFilter) + self.assertDictEqual(params, {}) + + def test_filter_for_IN_lookup(self): + f = Article._meta.get_field('author') + result, params = FilterSet.filter_for_lookup(f, 'in') + self.assertTrue(issubclass(result, ModelChoiceFilter)) + self.assertTrue(issubclass(result, BaseInFilter)) + self.assertEqual(params['to_field_name'], 'id') + + def test_filter_for_RANGE_lookup(self): + f = Article._meta.get_field('author') + result, params = FilterSet.filter_for_lookup(f, 'range') + self.assertTrue(issubclass(result, ModelChoiceFilter)) + self.assertTrue(issubclass(result, BaseRangeFilter)) + self.assertEqual(params['to_field_name'], 'id') + + def test_isnull_with_filter_overrides(self): + class OFilterSet(FilterSet): + filter_overrides = { + models.BooleanField: { + 'filter_class': BooleanFilter, + 'extra': lambda f: { + 'widget': BooleanWidget, + }, + }, + } + + f = Article._meta.get_field('author') + result, params = OFilterSet.filter_for_lookup(f, 'isnull') + self.assertEqual(result, BooleanFilter) + self.assertEqual(params['widget'], BooleanWidget) + + class FilterSetFilterForReverseFieldTests(TestCase): def test_reverse_o2o_relationship(self): @@ -327,13 +372,15 @@ class FilterSetClassCreationTests(TestCase): self.assertTrue(checkItemsEqual(list(F.base_filters), expected_list)) def test_meta_fields_containing_unknown(self): - with self.assertRaises(TypeError): + with self.assertRaises(TypeError) as excinfo: class F(FilterSet): username = CharFilter() class Meta: model = Book fields = ('username', 'price', 'other') + self.assertEqual(excinfo.exception.args, ( + "Meta.fields contains a field that isn't defined on this FilterSet: other",)) def test_meta_fields_dictionary_containing_unknown(self): with self.assertRaises(TypeError): @@ -537,7 +584,21 @@ class FilterSetOrderingTests(TestCase): def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self): class F(FilterSet): - strict = False + strict = STRICTNESS.IGNORE + + class Meta: + model = User + fields = ['username', 'status'] + order_by = ['status'] + + self.assertFalse(F.strict) + f = F({'o': 'username'}, queryset=self.qs) + self.assertQuerysetEqual( + f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) + + def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self): + class F(FilterSet): + strict = STRICTNESS.RAISE_VALIDATION_ERROR class Meta: model = User @@ -545,6 +606,14 @@ class FilterSetOrderingTests(TestCase): order_by = ['status'] f = F({'o': 'username'}, queryset=self.qs) + with self.assertRaises(ValidationError) as excinfo: + f.qs.all() + self.assertEqual(excinfo.exception.message_dict, + {'o': ['Select a valid choice. username is not one ' + 'of the available choices.']}) + + # No default order_by should get applied. + f = F({}, queryset=self.qs) self.assertQuerysetEqual( f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..90285c5 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,166 @@ + +import unittest + +import django +from django.test import TestCase +from django.db import models +from django.db.models.constants import LOOKUP_SEP +from django.core.exceptions import FieldError + +from django_filters.utils import get_model_field, resolve_field + +from .models import User +from .models import Article +from .models import Book +from .models import HiredWorker +from .models import Business + + +class GetModelFieldTests(TestCase): + + def test_non_existent_field(self): + result = get_model_field(User, 'unknown__name') + self.assertIsNone(result) + + def test_related_field(self): + result = get_model_field(Business, 'hiredworker__worker') + self.assertEqual(result, HiredWorker._meta.get_field('worker')) + + +class ResolveFieldTests(TestCase): + + def test_resolve_plain_lookups(self): + """ + Check that the standard query terms can be correctly resolved. + eg, an 'EXACT' lookup on a user's username + """ + model_field = User._meta.get_field('username') + lookups = model_field.class_lookups.keys() + + # This is simple - the final ouput of an untransformed field is itself. + # The lookups are the default lookups registered to the class. + for term in lookups: + field, lookup = resolve_field(model_field, term) + self.assertIsInstance(field, models.CharField) + self.assertEqual(lookup, term) + + def test_resolve_forward_related_lookups(self): + """ + Check that lookups can be resolved for related fields + in the forwards direction. + """ + lookups = ['exact', 'gte', 'gt', 'lte', 'lt', 'in', 'isnull', ] + + # ForeignKey + model_field = Article._meta.get_field('author') + for term in lookups: + field, lookup = resolve_field(model_field, term) + self.assertIsInstance(field, models.ForeignKey) + self.assertEqual(lookup, term) + + # ManyToManyField + model_field = User._meta.get_field('favorite_books') + for term in lookups: + field, lookup = resolve_field(model_field, term) + self.assertIsInstance(field, models.ManyToManyField) + self.assertEqual(lookup, term) + + @unittest.skipIf(django.VERSION < (1, 9), "version does not reverse lookups") + def test_resolve_reverse_related_lookups(self): + """ + Check that lookups can be resolved for related fields + in the reverse direction. + """ + lookups = ['exact', 'gte', 'gt', 'lte', 'lt', 'in', 'isnull', ] + + # ManyToOneRel + model_field = User._meta.get_field('article') + for term in lookups: + field, lookup = resolve_field(model_field, term) + self.assertIsInstance(field, models.ManyToOneRel) + self.assertEqual(lookup, term) + + # ManyToManyRel + model_field = Book._meta.get_field('lovers') + for term in lookups: + field, lookup = resolve_field(model_field, term) + self.assertIsInstance(field, models.ManyToManyRel) + self.assertEqual(lookup, term) + + @unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") + def test_resolve_transformed_lookups(self): + """ + Check that chained field transforms are correctly resolved. + eg, a 'date__year__gte' lookup on an article's 'published' timestamp. + """ + # Use a DateTimeField, so we can check multiple transforms. + # eg, date__year__gte + model_field = Article._meta.get_field('published') + + standard_lookups = [ + 'exact', + 'iexact', + 'gte', + 'gt', + 'lte', + 'lt', + ] + + date_lookups = [ + 'year', + 'month', + 'day', + 'week_day', + ] + + datetime_lookups = date_lookups + [ + 'hour', + 'minute', + 'second', + ] + + # ex: 'date__gt' + for lookup in standard_lookups: + field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join(['date', lookup])) + self.assertIsInstance(field, models.DateField) + self.assertEqual(resolved_lookup, lookup) + + # ex: 'year__iexact' + for part in datetime_lookups: + for lookup in standard_lookups: + field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join([part, lookup])) + self.assertIsInstance(field, models.IntegerField) + self.assertEqual(resolved_lookup, lookup) + + # ex: 'date__year__lte' + for part in date_lookups: + for lookup in standard_lookups: + field, resolved_lookup = resolve_field(model_field, LOOKUP_SEP.join(['date', part, lookup])) + self.assertIsInstance(field, models.IntegerField) + self.assertEqual(resolved_lookup, lookup) + + @unittest.skipIf(django.VERSION < (1, 9), "version does not support transformed lookup expressions") + def test_resolve_implicit_exact_lookup(self): + # Use a DateTimeField, so we can check multiple transforms. + # eg, date__year__gte + model_field = Article._meta.get_field('published') + + field, lookup = resolve_field(model_field, 'date') + self.assertIsInstance(field, models.DateField) + self.assertEqual(lookup, 'exact') + + field, lookup = resolve_field(model_field, 'date__year') + self.assertIsInstance(field, models.IntegerField) + self.assertEqual(lookup, 'exact') + + def test_invalid_lookup_expression(self): + model_field = Article._meta.get_field('published') + + with self.assertRaises(FieldError): + field, lookup = resolve_field(model_field, 'invalid_lookup') + + def test_invalid_transformed_lookup_expression(self): + model_field = Article._meta.get_field('published') + + with self.assertRaises(FieldError): + field, lookup = resolve_field(model_field, 'date__invalid_lookup') diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 0a74905..f8873c3 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 CSVWidget from django_filters.widgets import RangeWidget from django_filters.widgets import LinkWidget from django_filters.widgets import LookupTypeWidget @@ -148,6 +149,15 @@ class RangeWidgetTests(TestCase): class BooleanWidgetTests(TestCase): """ """ + def test_widget_render(self): + w = BooleanWidget() + self.assertHTMLEqual(w.render('price', ''), """ + """) + def test_widget_value_from_datadict(self): """ """ @@ -163,3 +173,48 @@ class BooleanWidgetTests(TestCase): result = w.value_from_datadict({}, {}, 'active') self.assertEqual(result, None) + + +class CSVWidgetTests(TestCase): + def test_widget(self): + w = CSVWidget() + self.assertHTMLEqual(w.render('price', None), """ + """) + + self.assertHTMLEqual(w.render('price', ''), """ + """) + + self.assertHTMLEqual(w.render('price', '1,2'), """ + """) + + self.assertHTMLEqual(w.render('price', ['1', '2']), """ + """) + + self.assertHTMLEqual(w.render('price', [1, 2]), """ + """) + + def test_widget_value_from_datadict(self): + w = CSVWidget() + + data = {'price': None} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, None) + + data = {'price': '1'} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['1']) + + data = {'price': '1,2'} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['1', '2']) + + data = {'price': '1,,2'} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['1', '', '2']) + + data = {'price': ''} + result = w.value_from_datadict(data, {}, 'price') + self.assertEqual(result, ['']) + + result = w.value_from_datadict({}, {}, 'price') + self.assertEqual(result, None) 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