Luke Plant avatar Luke Plant committed 1e6e9d4

Improved handling of ranges by explicitly stating whether it is inclusive or not

Comments (0)

Files changed (2)

django_easyfilters/filters.py

         return retval
 
 
+class RangeEnd(object):
+    """
+    Simple structure to store part of a range
+    """
+    def __init__(self, value, inclusive):
+        # value is some generic value, inclusive is a bool specifying where this
+        # value is included as part of the range.
+        self.value, self.inclusive = value, inclusive
+
 def make_numeric_range_choice(to_python, to_str):
     """
     Returns a Choice class that represents a numeric choice range,
 
     class NumericRangeChoice(object):
         def __init__(self, values):
+            # Values are instances of RangeEnd
             self.values = values
 
         def display(self):
-            return '-'.join(map(str, self.values))
+            return '-'.join([str(v.value) for v in self.values])
 
         @classmethod
         def from_param(cls, param):
             vals = []
             for p in param.split('..', 1):
+                inclusive = False
+                if p.endswith('i'):
+                    inclusive = True
+                    p = p[:-1]
+
                 try:
                     val = to_python(p)
-                    vals.append(val)
+                    vals.append(RangeEnd(val, inclusive))
                 except ValidationError:
                     raise ValueError()
             return cls(vals)
 
         def make_lookup(self, field_name):
             if len(self.values) == 1:
-                return {field_name: self.values[0]}
+                return {field_name: self.values[0].value}
             else:
-                return {field_name + '__gt': self.values[0],
-                        field_name + '__lte': self.values[1]}
+                return {field_name + '__gt' + ('e' if self.values[0].inclusive else ''):
+                            self.values[0].value,
+                        field_name + '__lt' + ('e' if self.values[1].inclusive else ''):
+                            self.values[1].value}
 
         def __unicode__(self):
-            return '..'.join(map(to_str, self.values))
+            return '..'.join([to_str(v.value) + ('i' if v.inclusive else '')
+                              for v in self.values])
 
         def __repr__(self):
             return '<NumericChoice %s>' % self.__unicode__()
                     return 0
                 else:
                     # Larger difference means less specific
-                    return -cmp(self.values[1] - self.values[0],
-                                other.values[1] - other.values[0])
+                    return -cmp(self.values[1].value - self.values[0].value,
+                                other.values[1].value - other.values[0].value)
 
     return NumericRangeChoice
 
         if num <= self.max_links:
             val_counts = value_counts(qs, self.field)
             for v, count in val_counts.items():
-                choice = self.choice_type([v])
+                choice = self.choice_type([RangeEnd(v, True)])
                 choices.append(FilterChoice(choice.display(),
                                             count,
                                             self.build_params(add=choice),
             ranges = [(lower + step * i, lower + step * (i+1)) for i in xrange(self.max_links)]
 
             val_counts = numeric_range_counts(qs, self.field, ranges)
-            for vals, count in val_counts.items():
-                choice = self.choice_type(vals)
+            for i, (vals, count) in enumerate(val_counts.items()):
+                # For the lower bound, we make it inclusive only if it the first
+                # choice. The upper bound is always inclusive. This gives
+                # filters that behave sensibly e.g. with 10-20, 20-30, 30-40,
+                # the first will include 10 and 20, the second will exlude 20.
+                lower_inclusive = i == 0
+                choice = self.choice_type([RangeEnd(vals[0], lower_inclusive),
+                                           RangeEnd(vals[1], True)])
                 choices.append(FilterChoice(choice.display(),
                                             count,
                                             self.build_params(add=choice),

django_easyfilters/tests/filterset.py

         total_count = sum(c.count for c in choices)
         self.assertEqual(total_count, qs.count())
 
+        # First choice should be inclusive on first and last
+        p0 = choices[0].params.getlist('price')[0]
+        self.assertTrue('..' in p0)
+        self.assertTrue('i' in p0.split('..')[0])
+        self.assertTrue('i' in p0.split('..')[1])
+
+        # Second choice should be exlusive on first,
+        # inclusive on second.
+        p1 = choices[1].params.getlist('price')[0]
+        self.assertTrue('..' in p1)
+        self.assertTrue('i' not in p1.split('..')[0])
+        self.assertTrue('i' in p1.split('..')[1])
+
     def test_numericrange_filter_apply_filter(self):
-        params = MultiValueDict({'price': ['3.50..4.00']})
-        filter1 = NumericRangeFilter('price', Book, params)
         qs = Book.objects.all()
 
-        qs_filtered = filter1.apply_filter(qs)
-        self.assertEqual(list(qs_filtered),
+        # exclusive
+        params1 = MultiValueDict({'price': ['3.50..4.00']})
+        filter1 = NumericRangeFilter('price', Book, params1)
+        qs_filtered1 = filter1.apply_filter(qs)
+        self.assertEqual(list(qs_filtered1),
                          list(qs.filter(price__gt=Decimal('3.50'),
+                                        price__lt=Decimal('4.00'))))
+
+        # inclusive
+        params2 = MultiValueDict({'price': ['3.50i..4.00i']})
+        filter2 = NumericRangeFilter('price', Book, params2)
+        qs_filtered2 = filter2.apply_filter(qs)
+        self.assertEqual(list(qs_filtered2),
+                         list(qs.filter(price__gte=Decimal('3.50'),
                                         price__lte=Decimal('4.00'))))
 
-
     def test_order_by_count(self):
         """
         Tests the 'order_by_count' option.
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.