diff --git a/README.rst b/README.rst index 6de34f1..c174508 100644 --- a/README.rst +++ b/README.rst @@ -12,9 +12,9 @@ Full documentation on `read the docs`_. Requirements ------------ -* Python 2.7, 3.3, 3.4, 3.5 -* Django 1.8, 1.9, 1.10 -* DRF 3.3 (Django 1.8 only), 3.4 +* **Python**: 2.7, 3.3, 3.4, 3.5 +* **Django**: 1.8, 1.9, 1.10 +* **DRF**: 3.4, 3.5 Installation ------------ diff --git a/docs/img/form.png b/docs/assets/form.png similarity index 100% rename from docs/img/form.png rename to docs/assets/form.png diff --git a/docs/dev/tests.txt b/docs/dev/tests.txt new file mode 100644 index 0000000..6410bac --- /dev/null +++ b/docs/dev/tests.txt @@ -0,0 +1,67 @@ +====================== +Running the Test Suite +====================== + +The easiest way to run the django-filter tests is to check out the source +code and create a virtualenv where you can install the test dependencies. +Django-filter uses a custom test runner to configure the environment, so a +wrapper script is available to set up and run the test suite. + +.. note:: + + The following assumes you have `virtualenv`__ and `git`__ installed. + +__ https://virtualenv.pypa.io/en/stable/ +__ https://git-scm.com + +Clone the repository +-------------------- + +Get the source code using the following command: + +.. code-block:: bash + + $ git clone https://github.com/carltongibson/django-filter.git + +Switch to the django-filter directory: + +.. code-block:: bash + + $ cd django-filter + +Set up the virtualenv +--------------------- + +Create a new virtualenv to run the test suite in: + +.. code-block:: bash + + $ virtualenv venv + +Then activate the virtualenv and install the test requirements: + +.. code-block:: bash + + $ source venv/bin/activate + $ pip install -r requirements/test.txt + +Execute the test runner +----------------------- + +Run the tests with the runner script: + +.. code-block:: bash + + $ python runtests.py + + +Test all supported versions +--------------------------- + +You can also use the excellent tox testing tool to run the tests against all +supported versions of Python and Django. Install tox, and then simply run: + +.. code-block:: bash + + $ pip install tox + $ tox diff --git a/docs/guide/install.txt b/docs/guide/install.txt new file mode 100644 index 0000000..5f836f9 --- /dev/null +++ b/docs/guide/install.txt @@ -0,0 +1,33 @@ +============ +Installation +============ + +Django-filter can be installed from PyPI with tools like ``pip``: + +.. code-block:: bash + + $ pip install django-filter + +Then add ``'django_filters'`` to your ``INSTALLED_APPS``. + +.. note:: + + django-filter provides *some* localization for *some* languages. If you do + not need these translations (or would rather provide your own), then it is + unnecessary to add django-filter to the ``INSTALLED_APPS`` setting. + + +Requirements +------------ + +Django-filter is tested against all supported versions of Python and `Django`__, +as well as the latest versions of Django REST Framework (`DRF`__). + +__ https://www.djangoproject.com/download/ +__ http://www.django-rest-framework.org/ + + + +* **Python**: 2.7, 3.3, 3.4, 3.5 +* **Django**: 1.8, 1.9, 1.10 +* **DRF**: 3.4, 3.5 diff --git a/docs/migration.txt b/docs/guide/migration.txt similarity index 99% rename from docs/migration.txt rename to docs/guide/migration.txt index aa2f8ec..8448e9f 100644 --- a/docs/migration.txt +++ b/docs/guide/migration.txt @@ -1,3 +1,4 @@ +================ Migrating to 1.0 ================ diff --git a/docs/rest_framework.txt b/docs/guide/rest_framework.txt similarity index 97% rename from docs/rest_framework.txt rename to docs/guide/rest_framework.txt index 3e2b893..bc4e5e8 100644 --- a/docs/rest_framework.txt +++ b/docs/guide/rest_framework.txt @@ -1,5 +1,6 @@ -Django Rest Framework -===================== +==================== +Integration with DRF +==================== Integration with `Django Rest Framework`__ is provided through a DRF-specific ``FilterSet`` and a `filter backend`__. These may be found in the ``rest_framework`` sub-package. @@ -72,8 +73,8 @@ To enable filtering with a ``FilterSet``, add it to the ``filter_class`` paramet filter_class = ProductFilter -Specifying ``filter_fields`` ----------------------------- +Using the ``filter_fields`` shortcut +------------------------------------ You may bypass creating a ``FilterSet`` by instead adding ``filter_fields`` to your view class. This is equivalent to creating a FilterSet with just :ref:`Meta.fields `. @@ -168,4 +169,4 @@ If you are using DRF's browsable API or admin API you may also want to install ` With crispy forms installed and added to Django's ``INSTALLED_APPS``, the browsable API will present a filtering control for ``DjangoFilterBackend``, like so: -.. image:: img/form.png +.. image:: ../assets/form.png diff --git a/docs/guide/tips.txt b/docs/guide/tips.txt new file mode 100644 index 0000000..7b46a8d --- /dev/null +++ b/docs/guide/tips.txt @@ -0,0 +1,215 @@ +================== +Tips and Solutions +================== + +Common problems for declared filters +------------------------------------ + +Below are some of the common problem that occur when declaring filters. It is +recommended that you read this as it provides a more complete understanding of +how filters work. + + +Filter ``name`` and ``lookup_expr`` not configured +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +While ``name`` and ``lookup_expr`` 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: + +.. code-block:: python + + 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: + +.. code-block:: python + + Produce.objects.filter(price__gt__exact=value) + +The above will most likely generate a ``FieldError``. The correct configuration +would be: + +.. code-block:: python + + class ProductFilter(django_filters.FilterSet): + price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt') + + +Missing ``lookup_expr`` for text search filters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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. + + +Filter and lookup expression mismatch (in, range, isnull) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It's not always appropriate to directly match a filter to its model field's +type, as some lookups expect different types of values. This is a commonly +found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look +at the following product model: + +.. code-block:: python + + class Product(models.Model): + category = models.ForeignKey(Category, null=True) + +Given that ``category`` is optional, it's reasonable to want to enable a search +for uncategorized products. The following is an incorrectly configured +``isnull`` filter: + +.. code-block:: python + + class ProductFilter(django_filters.FilterSet): + uncategorized = django_filters.NumberFilter(name='category', lookup_expr='isnull') + +So what's the issue? While the underlying column type for ``category`` is an +integer, ``isnull`` lookups expect a boolean value. A ``NumberFilter`` however +only validates numbers. Filters are not `'expression aware'` and won't change +behavior based on their ``lookup_expr``. You should use filters that match the +data type of the lookup expression `instead` of the data type underlying the +model field. The following would correctly allow you to search for both +uncategorized products and products for a set of categories: + +.. code-block:: python + + class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): + pass + + class ProductFilter(django_filters.FilterSet): + categories = NumberInFilter(name='category', lookup_expr='in') + uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull') + +More info on constructing ``in`` and ``range`` csv :ref:`filters `. + + +Filtering by empty values +------------------------- + +There are a number of cases where you may need to filter by empty or null +values. The following are some common solutions to these problems: + + +Filtering by null values +~~~~~~~~~~~~~~~~~~~~~~~~ + +As explained in the above "Filter and lookup expression mismatch" section, a +common problem is how to correctly filter by null values on a field. + +Solution 1: Using a ``BooleanFilter`` with ``isnull`` +""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Using ``BooleanFilter`` with an ``isnull`` lookup is a builtin solution used by +the FilterSet's automatic filter generation. To do this manually, simply add: + +.. code-block:: python + + class ProductFilter(django_filters.FilterSet): + uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull') + +.. note:: + + Remember that the filter class is validating the input value. The underlying + type of the mode field is not relevant here. + +You may also reverse the logic with the ``exclude`` parameter. + +.. code-block:: python + + class ProductFilter(django_filters.FilterSet): + has_category = django_filters.BooleanFilter(name='category', lookup_expr='isnull', exclude=True) + +Solution 2: Using ``ChoiceFilter``'s null choice +"""""""""""""""""""""""""""""""""""""""""""""""" + +If you're using a ChoiceFilter, you may also filter by null values by enabling +the ``null_label`` parameter. More details in the ``ChoiceFilter`` reference +:ref:`docs `. + +.. code-block:: python + + class ProductFilter(django_filters.FilterSet): + category = django_filters.ModelChoiceFilter( + name='category', lookup_expr='isnull', + null_label='Uncategorized', + queryset=Category.objects.all(), + ) + +Solution 3: Combining fields w/ ``MultiValueField`` +"""""""""""""""""""""""""""""""""""""""""""""""""" + +An alternative approach is to use Django's ``MultiValueField`` to manually add +in a ``BooleanField`` to handle null values. Proof of concept: +https://github.com/carltongibson/django-filter/issues/446 + + +Filtering by an empty string +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It's not currently possible to filter by an empty string, since empty values are +interpreted as a skipped filter. + +.. code-block:: http + + GET http://localhost/api/my-model?myfield= + +Solution 1: Magic values +"""""""""""""""""""""""" + +You can override the ``filter()`` method of a filter class to specifically check +for magic values. This is similar to the ``ChoiceFilter``'s null value handling. + +.. code-block:: http + + GET http://localhost/api/my-model?myfield=EMPTY + +.. code-block:: python + + class MyCharFilter(filters.CharFilter): + empty_value = 'EMPTY' + + def filter(self, qs, value): + if value != self.empty_value: + return super(MyCharFilter, self).filter(qs, value) + + qs = self.get_method(qs)(**{'%s__%s' % (self.name, self.lookup_expr): ""}) + return qs.distinct() if self.distinct else qs + + +Solution 2: Empty string filter +""""""""""""""""""""""""""""""" + +It would also be possible to create an empty value filter that exhibits the same +behavior as an ``isnull`` filter. + +.. code-block:: http + + GET http://localhost/api/my-model?myfield__isempty=false + +.. code-block:: python + + from django.core.validators import EMPTY_VALUES + + class EmptyStringFilter(filters.BooleanFilter): + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + exclude = self.exclude ^ (value is False) + method = qs.exclude if exclude else qs.filter + + return method(**{self.name: ""}) + + + class MyFilterSet(filters.FilterSet): + myfield__isempty = EmptyStringFilter(name='myfield') + + class Meta: + model = MyModel diff --git a/docs/usage.txt b/docs/guide/usage.txt similarity index 75% rename from docs/usage.txt rename to docs/guide/usage.txt index 180b652..8860a23 100644 --- a/docs/usage.txt +++ b/docs/guide/usage.txt @@ -1,5 +1,6 @@ -Using django-filter -=================== +=============== +Getting Started +=============== Django-filter provides a simple way to filter down a queryset based on parameters a user provides. Say we have a ``Product`` model and we want to let @@ -80,82 +81,6 @@ 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 -Common declarative problems -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Below are some of the common problem that occur when declaring filters. It is -recommended that you do read this as it provides a more complete understanding -on how filters work. - - -Filter ``name`` and ``lookup_expr`` not configured -"""""""""""""""""""""""""""""""""""""""""""""""""" - -While ``name`` and ``lookup_expr`` 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) - -The above will most likely generate a ``FieldError``. The correct configuration -would be:: - - class ProductFilter(django_filters.FilterSet): - price__gt = django_filters.NumberFilter(name='price', lookup_expr='gt') - - -Missing ``lookup_expr`` for text search filters -""""""""""""""""""""""""""""""""""""""""""""""" - -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. - - -Filter and lookup expression mismatch (in, range, isnull) -""""""""""""""""""""""""""""""""""""""""""""""""""""""""" - -It's not always appropriate to directly match a filter to its model field's -type, as some lookups expect different types of values. This is a commonly -found issue with ``in``, ``range``, and ``isnull`` lookups. Let's look -at the following product model:: - - class Product(models.Model): - category = models.ForeignKey(Category, null=True) - -Given that ``category`` is optional, it's reasonable to want to enable a search -for uncategorized products. The following is an incorrectly configured -``isnull`` filter:: - - class ProductFilter(django_filters.FilterSet): - uncategorized = django_filters.NumberFilter(name='category', lookup_expr='isnull') - -So what's the issue? While the underlying column type for ``category`` is an -integer, ``isnull`` lookups expect a boolean value. A ``NumberFilter`` however -only validates numbers. Filters are not `'expression aware'` and won't change -behavior based on their ``lookup_expr``. You should use filters that match the -data type of the lookup expression `instead` of the data type underlying model -field. The following would correctly allow you to search for both uncategorized -products and products for a set of categories:: - - class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): - pass - - class ProductFilter(django_filters.FilterSet): - categories = NumberInFilter(name='category', lookup_expr='in') - uncategorized = django_filters.BooleanFilter(name='category', lookup_expr='isnull') - -More info on constructing IN and RANGE csv :ref:`filters `. - - Generating filters with Meta.fields ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -223,7 +148,7 @@ default filters for all the models fields of the same kind using models.BooleanField: { 'filter_class': django_filters.BooleanFilter, 'extra': lambda f: { - 'widget': 'forms.CheckboxInput', + 'widget': forms.CheckboxInput, }, }, } diff --git a/docs/index.txt b/docs/index.txt index 5d0c5c7..cb46017 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -7,18 +7,28 @@ the more mundane bits of view code. Specifically, it allows users to filter down a queryset based on a model's fields, displaying the form to let them do this. -Contents: +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + guide/install + guide/usage + guide/rest_framework + guide/tips + guide/migration .. toctree:: :maxdepth: 1 + :caption: Reference Documentation - install - usage - rest_framework ref/filterset ref/filters ref/fields ref/widgets ref/settings - migration - tests + +.. toctree:: + :maxdepth: 1 + :caption: Developer Documentation + + dev/tests diff --git a/docs/install.txt b/docs/install.txt deleted file mode 100644 index 044e55e..0000000 --- a/docs/install.txt +++ /dev/null @@ -1,16 +0,0 @@ -Installing django-filter ------------------------- - -Install with pip: - -.. code-block:: bash - - pip install django-filter - -And then add ``'django_filters'`` to your ``INSTALLED_APPS``. - -.. note:: - - django-filter provides *some* localization for *some* languages. If you do - not need these translations (or would rather provide your own), then it is - unnecessary to add django-filter to the ``INSTALLED_APPS`` setting. diff --git a/docs/ref/fields.txt b/docs/ref/fields.txt index 940724f..d73c493 100644 --- a/docs/ref/fields.txt +++ b/docs/ref/fields.txt @@ -1,5 +1,6 @@ -Fields Reference -================ +=============== +Field Reference +=============== ``IsoDateTimeField`` ~~~~~~~~~~~~~~~~~~~~ @@ -10,7 +11,9 @@ Defines a class level attribute ``ISO_8601`` as constant for the format. Sets ``input_formats = [ISO_8601]`` — this means that by default ``IsoDateTimeField`` will **only** parse ISO 8601 formated dates. -You may set ``input_formats`` to your list of required formats as per the `DateTimeField Docs`_, using the ``ISO_8601`` class level attribute to specify the ISO 8601 format. :: +You may set ``input_formats`` to your list of required formats as per the `DateTimeField Docs`_, using the ``ISO_8601`` class level attribute to specify the ISO 8601 format. + +.. code-block:: python f = IsoDateTimeField() f.input_formats = [IsoDateTimeField.ISO_8601] + DateTimeField.input_formats diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt index 0d3c1b4..556f0b0 100644 --- a/docs/ref/filters.txt +++ b/docs/ref/filters.txt @@ -1,3 +1,4 @@ +================ Filter Reference ================ @@ -191,6 +192,8 @@ This filter matches UUID values, used with ``models.UUIDField`` by default. This filter matches a boolean, either ``True`` or ``False``, used with ``BooleanField`` and ``NullBooleanField`` by default. +.. _choice-filter: + ``ChoiceFilter`` ~~~~~~~~~~~~~~~~ diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index a4d851b..2f4929e 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -1,5 +1,6 @@ -FilterSet Guide -=============== +================= +FilterSet Options +================= This document provides a guide on using additional FilterSet features. @@ -9,7 +10,6 @@ Meta options - :ref:`model ` - :ref:`fields ` - :ref:`exclude ` -- :ref:`order_by ` - :ref:`form
` - :ref:`together ` - filter_overrides @@ -91,40 +91,6 @@ declared directly on the ``FilterSet``. exclude = ['password'] -.. _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``'s Meta class to the string value you would like to use. - - .. _form: Custom Forms using ``form`` @@ -201,29 +167,3 @@ filters for a model field, you can override ``filter_for_lookup()``. Ex:: # 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 index 442b24e..bc59ca9 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1,7 +1,5 @@ -.. _ref-settings: - ================== -Available Settings +Settings Reference ================== Here is a list of all available settings of django-filters and their @@ -94,6 +92,6 @@ For example, you could add verbose output for "exact" lookups. FILTERS_STRICTNESS ------------------ -DEFAULT: ``STRICTNESS.RETURN_NO_RESULTS`` +Default: ``STRICTNESS.RETURN_NO_RESULTS`` Set the global default for FilterSet :ref:`strictness `. diff --git a/docs/ref/widgets.txt b/docs/ref/widgets.txt index d054c58..6af96d5 100644 --- a/docs/ref/widgets.txt +++ b/docs/ref/widgets.txt @@ -1,3 +1,4 @@ +================ Widget Reference ================ @@ -29,7 +30,9 @@ placeholders: This widget converts its input into Python's True/False values. It will convert all case variations of ``True`` and ``False`` into the internal Python values. -To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``:: +To use it, pass this into the ``widgets`` argument of the ``BooleanFilter``: + +.. code-block:: python active = BooleanFilter(widget=BooleanWidget()) @@ -53,6 +56,8 @@ This widget is used with ``RangeFilter`` and its subclasses. It generates two form input elements which generally act as start/end values in a range. Under the hood, it is django's ``forms.TextInput`` widget and excepts the same arguments and values. To use it, pass it to ``widget`` argument of -a ``RangeField``:: +a ``RangeField``: + +.. code-block:: python date_range = DateFromToRangeFilter(widget=RangeWidget(attrs={'placeholder': 'YYYY/MM/DD'})) diff --git a/docs/tests.txt b/docs/tests.txt deleted file mode 100644 index fa22bf6..0000000 --- a/docs/tests.txt +++ /dev/null @@ -1,68 +0,0 @@ -Running the django-filter tests -=============================== - -The easiest way to run the django-filter tests is to check out the source -code into a virtualenv, where you can install the test dependencies. -django-filter uses a custom test runner to locate all of the tests, so a -wrapper script is available to set up and run the test suite. - -.. note:: - - The following assumes you have `virtualenv`__ and `git`__ installed. - -__ http://www.virtualenv.org -__ http://git-scm.com - -Set up a virtualenv for the test suite --------------------------------------- - -Run the following to create a new virtualenv to run the test suite in:: - -.. code-block:: bash - - virtualenv django-filter-tests - cd django-filter-tests - . bin/activate - -Get a copy of django-filter ---------------------------- - -Get the django-filter source code using the following command:: - -.. code-block:: bash - - git clone https://github.com/alex/django-filter.git - -Switch to the django-filter directory:: - -.. code-block:: bash - - cd django-filter - -Install the test dependencies ------------------------------ - -Run the following to install the test dependencies within the -virutalenv:: - -.. code-block:: bash - - pip install -r requirements/test.txt - -Run the django-filter tests:: - -.. code-block:: bash - - python runtests.py - - -Testing all supported versions ------------------------------- - -You can also use the excellent tox testing tool to run the tests against all supported versions of -Python and Django. Install tox globally, and then simply run:: - -.. code-block:: bash - - tox -