Repository: carltongibson/django-filter

Issue:
Potential issue using 'together' filterset
When using the 'together' filterset, I find that I cannot send a query with parameter value 0. For example, suppose my filter defines:

```python
class Meta:
    fields = ['balance', 'total']
    together = ['balance', 'total']
```

A request with params { 'balance': 0, 'total': 10 } will return empty, even if there is a valid match. The issue seems to be in the get_full_clean_override function within filterset.py, specifically this line: 

```python
count = len([i for i in fieldset if cleaned_data.get(i)])  # line 50 in the 'develop' branch
```

One possible fix is to change this line as follows: 

```python
count = len([i for i in fieldset if cleaned_data.get(i) is not None])
```


Discussion:
OK. Yes. There's always issues with _Falsey_ values... First thing is a test case on this but I'd happily review a patch. 

Would `None` or `filters.EMPTY_VALUES` be a more appropriate check?

Happy to submit a patch. Whether we should use `None` or `filters.EMPTY_VALUES` depends on the desired behaviour. For example, for a field which may be blank, is setting the filter value to '' sufficient for a togetherness check? In my opinion, a check against None is more appropriate, because it maps directly to the case where the filter is not specified at all. 

> ...depends on the desired behaviour.

What happens if we combine `FieldX` with `FieldY`? What happens if we want to allow the `blank` value? Etc. 

First thing is to think through the obvious combinations and set up simple test cases for those. 

Then I suspect the fix will be easy enough. (Either that or impossible 😀)

Thank you for the guidance, I will set up some test cases. 

@sanjredfield Cool. Feel free to open a PR to solicit feedback


Resolving patch:
diff --git a/django_filters/constants.py b/django_filters/constants.py
--- a/django_filters/constants.py
+++ b/django_filters/constants.py
@@ -2,6 +2,9 @@
 ALL_FIELDS = '__all__'
 
 
+EMPTY_VALUES = ([], (), {}, '', None)
+
+
 class STRICTNESS(object):
     class IGNORE(object):
         pass
diff --git a/django_filters/filters.py b/django_filters/filters.py
--- a/django_filters/filters.py
+++ b/django_filters/filters.py
@@ -14,6 +14,7 @@
 from django.utils.translation import ugettext_lazy as _
 
 from .conf import settings
+from .constants import EMPTY_VALUES
 from .fields import (
     Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
     DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
@@ -56,9 +57,6 @@
 LOOKUP_TYPES = sorted(QUERY_TERMS)
 
 
-EMPTY_VALUES = ([], (), {}, '', None)
-
-
 class Filter(object):
     creation_counter = 0
     field_class = forms.Field
diff --git a/django_filters/filterset.py b/django_filters/filterset.py
--- a/django_filters/filterset.py
+++ b/django_filters/filterset.py
@@ -5,7 +5,6 @@
 from collections import OrderedDict
 
 from django import forms
-from django.forms.forms import NON_FIELD_ERRORS
 from django.db import models
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.fields.related import ForeignObjectRel
@@ -13,7 +12,7 @@
 
 from .conf import settings
 from .compat import remote_field, remote_queryset
-from .constants import ALL_FIELDS, STRICTNESS
+from .constants import ALL_FIELDS, STRICTNESS, EMPTY_VALUES
 from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter,
                       ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
                       ModelMultipleChoiceFilter, NumberFilter, UUIDFilter, DurationFilter)
@@ -36,28 +35,30 @@ def get_filter_name(field_name, lookup_expr):
     return filter_name
 
 
-def get_full_clean_override(together):
-    def full_clean(form):
+def _together_valid(form, fieldset):
+    field_presence = [
+        form.cleaned_data.get(field) not in EMPTY_VALUES
+        for field in fieldset
+    ]
 
-        def add_error(message):
-            try:
-                form.add_error(None, message)
-            except AttributeError:
-                form._errors[NON_FIELD_ERRORS] = message
+    if any(field_presence):
+        return all(field_presence)
+    return True
 
-        def all_valid(fieldset):
-            cleaned_data = form.cleaned_data
-            count = len([i for i in fieldset if cleaned_data.get(i)])
-            return 0 < count < len(fieldset)
 
+def get_full_clean_override(together):
+    # coerce together to list of pairs
+    if isinstance(together[0], (six.string_types)):
+        together = [together]
+
+    def full_clean(form):
         super(form.__class__, form).full_clean()
         message = 'Following fields must be together: %s'
-        if isinstance(together[0], (list, tuple)):
-            for each in together:
-                if all_valid(each):
-                    return add_error(message % ','.join(each))
-        elif all_valid(together):
-            return add_error(message % ','.join(together))
+
+        for each in together:
+            if not _together_valid(form, each):
+                return form.add_error(None, message % ','.join(each))
+
     return full_clean
 
 


Test patch:
diff --git a/tests/test_filterset.py b/tests/test_filterset.py
--- a/tests/test_filterset.py
+++ b/tests/test_filterset.py
@@ -718,6 +718,18 @@ class Meta:
         self.assertEqual(f.qs.count(), 1)
         self.assertQuerysetEqual(f.qs, [self.alex.pk], lambda o: o.pk)
 
+    def test_empty_values(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                together = ['username', 'status']
+
+        f = F({'username': '', 'status': ''}, queryset=self.qs)
+        self.assertEqual(f.qs.count(), 2)
+        f = F({'username': 'alex', 'status': ''}, queryset=self.qs)
+        self.assertEqual(f.qs.count(), 0)
+
 
 # test filter.method here, as it depends on its parent FilterSet
 class FilterMethodTests(TestCase):