Commits

Luke Plant committed b050b2d

Implemented 'nice' ranges for NumericRangeFilter

Comments (0)

Files changed (5)

django_easyfilters/filters.py

         # 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,

django_easyfilters/ranges.py

+"""
+Utilities to produce ranges of values for filters
+"""
+
+from decimal import Decimal, DecimalTuple, ROUND_HALF_EVEN, ROUND_DOWN, ROUND_UP
+import math
+
+
+def round_dec(d):
+    return d._rescale(0, ROUND_HALF_EVEN)
+
+def round_dec_down(d):
+    return d._rescale(0, ROUND_DOWN)
+
+def round_dec_up(d):
+    return d._rescale(0, ROUND_UP)
 
 
 def auto_ranges(lower, upper, max_items):
-    # TODO - round to produce nice looking ranges.
-    step = (upper - lower)/max_items
-    ranges = [(lower + step * i, lower + step * (i+1)) for i in xrange(max_items)]
-    return ranges
+    if lower == upper:
+        return [(lower, upper)]
+
+    assert lower < upper
+
+    # Convert to decimals.
+    lower_d = Decimal(lower)
+    upper_d = Decimal(upper)
+
+    step = (upper_d - lower_d)/max_items
+
+    # For human presentable numbers, 'step' will preferable be something like 2,
+    # 5, 10, or 0.1, 0.2, 0.5. etc.
+    st = step.as_tuple()
+
+    # It's not very helpful having exponent > 0, because these get displayed as
+    # 1E+1 etc. But we also don't want things like 10.00000. So we make sure
+    # we don't increase the exponent of step above zero
+
+    exponent_offset = len(st.digits)
+    if exponent_offset + st.exponent > 0:
+        exponent_offset = -st.exponent
+    zeros = [0] * (len(st.digits) - 1 - exponent_offset)
+
+    candidate_steps = [Decimal(DecimalTuple(sign=st.sign,
+                                            digits=[d] + zeros,
+                                            exponent=st.exponent + exponent_offset))
+                       for d in (1, 2, 5)]
+    # Go one order bigger as well:
+    candidate_steps.append(Decimal(DecimalTuple(sign=st.sign,
+                                                digits=[1, 0] + zeros,
+                                                exponent=st.exponent + exponent_offset)))
+
+    for c_step in candidate_steps:
+        # Use c_step to do rounding as well.
+        lower_r = round_dec_down(lower_d / c_step) * c_step
+        upper_r = round_dec_up(upper_d / c_step) * c_step
+        num_steps = int(round_dec((upper_r - lower_r) / c_step))
+        # If we are less than max_items, go with this.  (Note that smaller steps
+        # are tried first).
+        if num_steps <= max_items:
+            ranges = [(lower_r + c_step * i, lower_r + c_step * (i + 1))
+                      for i in xrange(num_steps)]
+            # make sure top item is rounded value
+            ranges[-1] = (ranges[-1][0],  upper_r)
+            return ranges
+
+    assert False, "Can't find a candidate set of ranges, logic error"

django_easyfilters/tests/__init__.py

 from filterset import *
+from ranges import *
 

django_easyfilters/tests/filterset.py

         self.assertEqual(choices[0].count, qs.filter(price__gte=Decimal('3.50'), price__lte=Decimal('5.00')).count())
         self.assertEqual(choices[1].count, qs.filter(price__gt=Decimal('5.00'), price__lte=Decimal('6.00')).count())
 
-
     def test_numericrange_filter_manual_ranges_no_drill_down(self):
         # We shouldn't get drilldown if ranges is specified manually.
 
         self.assertEqual(len(choices), 1)
         self.assertEqual(choices[0].link_type, FILTER_REMOVE)
 
-
     def test_order_by_count(self):
         """
         Tests the 'order_by_count' option.

django_easyfilters/tests/ranges.py

+from decimal import Decimal
+import unittest
+
+from django_easyfilters.ranges import auto_ranges
+
+
+class TestRanges(unittest.TestCase):
+
+    def test_auto_ranges_simple(self):
+        """
+        Test that auto_ranges produces 'nice' looking automatic ranges.
+        """
+        # An easy case - max_items is just what we want
+        ranges1 = auto_ranges(Decimal('15.0'), Decimal('20.0'), 5)
+        self.assertEqual(ranges1,
+                         [(Decimal('15.0'), Decimal('16.0')),
+                          (Decimal('16.0'), Decimal('17.0')),
+                          (Decimal('17.0'), Decimal('18.0')),
+                          (Decimal('18.0'), Decimal('19.0')),
+                          (Decimal('19.0'), Decimal('20.0'))])
+
+    def test_auto_ranges_flexible_max_items(self):
+        # max_items is a bit bigger than what we want,
+        # but we should be flexible if there is an easy target.
+        ranges1 = auto_ranges(Decimal('15.0'), Decimal('20.0'), 6)
+        self.assertEqual(ranges1,
+                         [(Decimal('15.0'), Decimal('16.0')),
+                          (Decimal('16.0'), Decimal('17.0')),
+                          (Decimal('17.0'), Decimal('18.0')),
+                          (Decimal('18.0'), Decimal('19.0')),
+                          (Decimal('19.0'), Decimal('20.0'))])
+
+    def test_auto_ranges_round_limits(self):
+        # start and end limits should be rounded to something nice
+
+        # Check with 5-10, 50-100, 15-20, 150-200
+
+        ranges1 = auto_ranges(Decimal('15.1'), Decimal('19.9'), 5)
+        self.assertEqual(ranges1,
+                         [(Decimal('15.0'), Decimal('16.0')),
+                          (Decimal('16.0'), Decimal('17.0')),
+                          (Decimal('17.0'), Decimal('18.0')),
+                          (Decimal('18.0'), Decimal('19.0')),
+                          (Decimal('19.0'), Decimal('20.0'))])
+
+        ranges2 = auto_ranges(Decimal('151'), Decimal('199'), 5)
+        self.assertEqual(ranges2,
+                         [(Decimal('150'), Decimal('160')),
+                          (Decimal('160'), Decimal('170')),
+                          (Decimal('170'), Decimal('180')),
+                          (Decimal('180'), Decimal('190')),
+                          (Decimal('190'), Decimal('200'))])
+
+        ranges3 = auto_ranges(Decimal('5.1'), Decimal('9.9'), 5)
+        self.assertEqual(ranges3,
+                         [(Decimal('5.0'), Decimal('6.0')),
+                          (Decimal('6.0'), Decimal('7.0')),
+                          (Decimal('7.0'), Decimal('8.0')),
+                          (Decimal('8.0'), Decimal('9.0')),
+                          (Decimal('9.0'), Decimal('10.0'))])
+
+        ranges4 = auto_ranges(Decimal('51'), Decimal('99'), 5)
+        self.assertEqual(ranges4,
+                         [(Decimal('50'), Decimal('60')),
+                          (Decimal('60'), Decimal('70')),
+                          (Decimal('70'), Decimal('80')),
+                          (Decimal('80'), Decimal('90')),
+                          (Decimal('90'), Decimal('100'))])
+
+        ranges5 = auto_ranges(Decimal('3'), Decimal('6'), 5)
+        self.assertEqual(ranges5,
+                         [(Decimal('3'), Decimal('4')),
+                          (Decimal('4'), Decimal('5')),
+                          (Decimal('5'), Decimal('6'))])
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.