Luke Plant avatar Luke Plant committed 42eaeea

Fixed bugs with drill down for DateTimeFilter stopping too early by using FILTER_DISPLAY

Comments (0)

Files changed (3)

django_easyfilters/filters.py

         return value_counts(qs, self.field)
 
 
-class RangeFilterMixin(ChooseAgainMixin, SingleValueMixin):
+class RangeFilterMixin(ChooseAgainMixin):
 
     # choice_type must be set to a class that provides the static method
     # 'from_param' and instance methods 'make_lookup' and 'display', and the
     def render_choice_object(self, choice):
         return choice.display()
 
+    def get_choices_remove(self, qs):
+        chosen = list(self.chosen)
+        out = []
+
+        for i, choice in enumerate(chosen):
+            # As for RangeFilterMixin, if a broader param is removed, the more
+            # specific params must be removed too.
+            to_remove = [c for c in chosen if c >= choice]
+            out.append(FilterChoice(self.render_choice_object(choice),
+                                    None,
+                                    self.build_params(remove=to_remove),
+                                    FILTER_REMOVE))
+            # There can be cases where there are gaps, so we need to bridge
+            # using FILTER_DISPLAY
+            out.extend(self.bridge_choices(chosen[0:i+1], chosen[i+1:]))
+        return out
+
     def get_choices_add(self, qs):
         chosen = list(self.chosen)
-        range_type = None
 
-        if len(chosen) > 0:
-            range_type = chosen[-1].range_type.drilldown()
+        # For the case of needing to drill down past a single option
+        # to get to some real choices, we define a recursive
+        # function.
+
+        def get_choices_add_recursive(chosen):
+            range_type = None
+
+            if len(chosen) > 0:
+                range_type = chosen[-1].range_type.drilldown()
+                if range_type is None:
+                    return []
+
             if range_type is None:
-                return []
+                # Get some initial idea of range
+                date_range = qs.aggregate(first=models.Min(self.field),
+                                          last=models.Max(self.field))
+                first = date_range['first']
+                last = date_range['last']
+                if first is None or last is None:
+                    # No values, can't drill down:
+                    return []
+                if first.year == last.year:
+                    if first.month == last.month:
+                        range_type = DAY
+                    else:
+                        range_type = MONTH
+                else:
+                    range_type = YEAR
 
-        if range_type is None:
-            # Get some initial idea of range
-            date_range = qs.aggregate(first=models.Min(self.field),
-                                      last=models.Max(self.field))
-            first = date_range['first']
-            last = date_range['last']
-            if first is None or last is None:
-                # No values, can't drill down:
-                return []
-            if first.year == last.year:
-                if first.month == last.month:
-                    range_type = DAY
+            date_qs = qs.dates(self.field, range_type.label)
+            results = date_aggregation(date_qs)
+
+            date_choice_counts = self.collapse_results(results, range_type)
+            if len(date_choice_counts) == 1 and range_type is not None:
+                # Single choice - recurse.
+                single_choice, count = date_choice_counts[0]
+                date_choice_counts_deeper = get_choices_add_recursive([single_choice])
+                if len(date_choice_counts_deeper) == 0:
+                    # Nothing there, so ignore
+                    return date_choice_counts
                 else:
-                    range_type = MONTH
+                    # We discard date_choice_counts, because bridge_choices will
+                    # make it up again.
+                    return date_choice_counts_deeper
             else:
-                range_type = YEAR
+                return date_choice_counts
 
-        date_qs = qs.dates(self.field, range_type.label)
-        results = date_aggregation(date_qs)
-
-        date_choice_counts = self.collapse_results(results, range_type)
+        date_choice_counts = get_choices_add_recursive(chosen)
 
         choices = []
         # Additional display links, to give context for choices if necessary.
         if len(date_choice_counts) > 0:
-            choices.extend(self.bridge_choices(chosen, date_choice_counts))
+            choices.extend(self.bridge_choices(chosen,
+                                               [choice for choice, count in date_choice_counts]))
 
         for date_choice, count in date_choice_counts:
             if date_choice in chosen:
 
             # To ensure we get the bridge choices, which are useful, we check
             # self.max_depth_level late on and bailout here.
-            if range_type.level > self.max_depth_level:
+            if date_choice.range_type.level > self.max_depth_level:
                 continue
 
+            if (len(date_choice_counts) == 1 and
+                (date_choice.range_type.level == self.max_depth_level or
+                 count == 1)):
+                link_type = FILTER_DISPLAY
+            else:
+                link_type = FILTER_ADD
+
             choices.append(FilterChoice(self.render_choice_object(date_choice),
                                         count,
                                         self.build_params(add=date_choice),
-                                        FILTER_ADD))
+                                        link_type))
         return choices
 
     def collapse_results(self, results, range_type):
 
     def bridge_choices(self, chosen, choices):
         # Returns FILTER_DISPLAY type choices to bridge from what is chosen
-        # (which might be nothing) to the first 'add' link, to give context to
-        # the link.
+        # (which might be nothing) to what can be chosen, to give context to the
+        # link.
+
+        # Note this is used is bridging to the 'add' choices, and in bridging
+        # between 'remove' choices
+
         if len(choices) == 0:
             return []
         if len(chosen) == 0:
 
         # first choice in list can act as template, as it will have all the
         # values we need.
-        new_choice = choices[0][0]
+        new_choice = choices[0]
         new_level = new_choice.range_type.level
 
         retval = []
             retval.append(FilterChoice(self.render_choice_object(date_choice),
                                        None, None,
                                        FILTER_DISPLAY))
+
         return retval
 
 
     return NumericRangeChoice
 
 
-class NumericRangeFilter(RangeFilterMixin, Filter):
+class NumericRangeFilter(RangeFilterMixin, SingleValueMixin, Filter):
 
     def __init__(self, field, model, params, **kwargs):
         self.max_links = kwargs.pop('max_links', 5)

django_easyfilters/tests/filterset.py

     FILTER_ADD, FILTER_REMOVE, FILTER_DISPLAY, \
     ForeignKeyFilter, ValuesFilter, ChoicesFilter, ManyToManyFilter, DateTimeFilter, NumericRangeFilter
 
-from models import Book, Genre, Author, BINDING_CHOICES
+from models import Book, Genre, Author, BINDING_CHOICES, Person
 
 
 class TestFilterSet(TestCase):
         # the month should be displayed in 'display' mode.
         qs = Book.objects.filter(id=1)
         params = MultiValueDict(dict(date_published=[str(qs[0].date_published.year)]))
-        f = DateTimeFilter('date_published', Book, params, max_links=10)
+        f = DateTimeFilter('date_published', Book, params, max_links=10, max_depth='month')
 
         choices = f.get_choices(qs)
         self.assertEqual(len(choices), 2)
                               ])
 
 
+    def test_datetime_filter_drill_down_to_choice(self):
+        """
+        Tests that if there is a choice that can be displayed, it will drill
+        down to reach it.
+        """
+        # Two birthdays in Jan 2011
+        Person.objects.create(name="Joe", date_of_birth=date(2011, 1, 10))
+        Person.objects.create(name="Peter", date_of_birth=date(2011, 1, 20))
+
+        # Chosen year = 2011
+        params = MultiValueDict({'date_of_birth':['2011']})
+
+        f = DateTimeFilter('date_of_birth', Person, params)
+        qs = Person.objects.all()
+        qs_filtered = f.apply_filter(qs)
+        choices = f.get_choices(qs_filtered)
+
+        # Expect 2011 as remove link
+        self.assertEqual(['2011'], [c.label for c in choices if c.link_type == FILTER_REMOVE])
+        # Expect January as display
+        self.assertEqual(['January'], [c.label for c in choices if c.link_type == FILTER_DISPLAY])
+        # Expect '10' and '20' as choices
+        self.assertEqual(['10', '20'], [c.label for c in choices if c.link_type == FILTER_ADD])
+
+    def test_datetime_filter_remove_choices_complete(self):
+        """
+        Tests that in the case produced in test_datetime_filter_drill_down_to_choice,
+        the remove links display correctly.
+        down to reach it.
+        """
+        # Two birthdays in Jan 2011
+        Person.objects.create(name="Joe", date_of_birth=date(2011, 1, 10))
+        Person.objects.create(name="Peter", date_of_birth=date(2011, 1, 20))
+
+        # Chosen year = 2011, and date = 2011-01-10
+        params = MultiValueDict({'date_of_birth':['2011', '2011-01-10']})
+
+        f = DateTimeFilter('date_of_birth', Person, params)
+        qs = Person.objects.all()
+        qs_filtered = f.apply_filter(qs)
+        choices = f.get_choices(qs_filtered)
+
+        self.assertEqual([('2011', FILTER_REMOVE),
+                          ('January', FILTER_DISPLAY),
+                          ('10', FILTER_REMOVE),
+                          ],
+                         [(c.label, c.link_type) for c in choices])
+
     def test_numericrange_filter_simple_vals(self):
         # If data is less than max_links, we should get a simple list of values.
         filter1 = NumericRangeFilter('price', Book, MultiValueDict(), max_links=20)

django_easyfilters/tests/models.py

     def __unicode__(self):
         return self.name
 
+
+class Person(models.Model):
+    date_of_birth = models.DateField()
+    name = models.CharField(max_length=50)
+
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.