diff core/paginator.py @ 581:ee87ea74d46b

For Django 1.4, rearranged project structure for new manage.py.
author Brian Neal <bgneal@gmail.com>
date Sat, 05 May 2012 17:10:48 -0500
parents gpp/core/paginator.py@dbd703f7d63a
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/paginator.py	Sat May 05 17:10:48 2012 -0500
@@ -0,0 +1,286 @@
+"""
+Digg.com style paginator.
+References:
+http://www.djangosnippets.org/snippets/773/
+http://blog.elsdoerfer.name/2008/05/26/diggpaginator-update/
+http://blog.elsdoerfer.name/2008/03/06/yet-another-paginator-digg-style/
+"""
+import math
+from django.core.paginator import \
+    Paginator, QuerySetPaginator, Page, InvalidPage
+
+__all__ = (
+    'InvalidPage',
+    'ExPaginator',
+    'DiggPaginator',
+    'QuerySetDiggPaginator',
+)
+
+class ExPaginator(Paginator):
+    """Adds a ``softlimit`` option to ``page()``. If True, querying a
+    page number larger than max. will not fail, but instead return the
+    last available page.
+
+    This is useful when the data source can not provide an exact count
+    at all times (like some search engines), meaning the user could
+    possibly see links to invalid pages at some point which we wouldn't
+    want to fail as 404s.
+
+    >>> items = range(1, 1000)
+    >>> paginator = ExPaginator(items, 10)
+    >>> paginator.page(1000)
+    Traceback (most recent call last):
+    InvalidPage: That page contains no results
+    >>> paginator.page(1000, softlimit=True)
+    <Page 100 of 100>
+
+    # [bug] graceful handling of non-int args
+    >>> paginator.page("str")
+    Traceback (most recent call last):
+    InvalidPage: That page number is not an integer
+    """
+    def _ensure_int(self, num, e):
+        # see Django #7307
+        try:
+            return int(num)
+        except ValueError:
+            raise e
+
+    def page(self, number, softlimit=False):
+        try:
+            return super(ExPaginator, self).page(number)
+        except InvalidPage, e:
+            number = self._ensure_int(number, e)
+            if number > self.num_pages and softlimit:
+                return self.page(self.num_pages, softlimit=False)
+            else:
+                raise e
+
+class DiggPaginator(ExPaginator):
+    """
+    Based on Django's default paginator, it adds "Digg-style" page ranges
+    with a leading block of pages, an optional middle block, and another
+    block at the end of the page range. They are available as attributes
+    on the page:
+
+    {# with: page = digg_paginator.page(1) #}
+    {% for num in page.leading_range %} ...
+    {% for num in page.main_range %} ...
+    {% for num in page.trailing_range %} ...
+
+    Additionally, ``page_range`` contains a nun-numeric ``False`` element
+    for every transition between two ranges.
+
+    {% for num in page.page_range %}
+        {% if not num %} ...  {# literally output dots #}
+        {% else %}{{ num }}
+        {% endif %}
+    {% endfor %}
+
+    Additional arguments passed to the constructor allow customization of
+    how those bocks are constructed:
+
+    body=5, tail=2
+
+    [1] 2 3 4 5 ... 91 92
+    |_________|     |___|
+    body            tail
+              |_____|
+              margin
+
+    body=5, tail=2, padding=2
+
+    1 2 ... 6 7 [8] 9 10 ... 91 92
+            |_|     |__|
+             ^padding^
+    |_|     |__________|     |___|
+    tail    body             tail
+
+    ``margin`` is the minimum number of pages required between two ranges; if
+    there are less, they are combined into one.
+
+    When ``align_left`` is set to ``True``, the paginator operates in a
+    special mode that always skips the right tail, e.g. does not display the
+    end block unless necessary. This is useful for situations in which the
+    exact number of items/pages is not actually known.
+
+    # odd body length
+    >>> print DiggPaginator(range(1,1000), 10, body=5).page(1)
+    1 2 3 4 5 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5).page(100)
+    1 2 ... 96 97 98 99 100
+
+    # even body length
+    >>> print DiggPaginator(range(1,1000), 10, body=6).page(1)
+    1 2 3 4 5 6 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=6).page(100)
+    1 2 ... 95 96 97 98 99 100
+
+    # leading range and main range are combined when close; note how
+    # we have varying body and padding values, and their effect.
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2).page(3)
+    1 2 3 4 5 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=6, padding=2, margin=2).page(4)
+    1 2 3 4 5 6 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2).page(6)
+    1 2 3 4 5 6 7 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2).page(7)
+    1 2 ... 5 6 7 8 9 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2).page(7)
+    1 2 ... 5 6 7 8 9 ... 99 100
+
+    # the trailing range works the same
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2, ).page(98)
+    1 2 ... 96 97 98 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=6, padding=2, margin=2, ).page(97)
+    1 2 ... 95 96 97 98 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2, ).page(95)
+    1 2 ... 94 95 96 97 98 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2, ).page(94)
+    1 2 ... 92 93 94 95 96 ... 99 100
+    >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2, ).page(94)
+    1 2 ... 92 93 94 95 96 ... 99 100
+
+    # all three ranges may be combined as well
+    >>> print DiggPaginator(range(1,151), 10, body=6, padding=2).page(7)
+    1 2 3 4 5 6 7 8 9 ... 14 15
+    >>> print DiggPaginator(range(1,151), 10, body=6, padding=2).page(8)
+    1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
+    >>> print DiggPaginator(range(1,151), 10, body=6, padding=1).page(8)
+    1 2 3 4 5 6 7 8 9 ... 14 15
+
+    # no leading or trailing ranges might be required if there are only
+    # a very small number of pages
+    >>> print DiggPaginator(range(1,80), 10, body=10).page(1)
+    1 2 3 4 5 6 7 8
+    >>> print DiggPaginator(range(1,80), 10, body=10).page(8)
+    1 2 3 4 5 6 7 8
+    >>> print DiggPaginator(range(1,12), 10, body=5).page(1)
+    1 2
+
+    # test left align mode
+    >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(1)
+    1 2 3 4 5
+    >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(50)
+    1 2 ... 48 49 50 51 52
+    >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(97)
+    1 2 ... 95 96 97 98 99
+    >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(100)
+    1 2 ... 96 97 98 99 100
+
+    # padding: default value
+    >>> DiggPaginator(range(1,1000), 10, body=10).padding
+    4
+
+    # padding: automatic reduction
+    >>> DiggPaginator(range(1,1000), 10, body=5).padding
+    2
+    >>> DiggPaginator(range(1,1000), 10, body=6).padding
+    2
+
+    # padding: sanity check
+    >>> DiggPaginator(range(1,1000), 10, body=5, padding=3)
+    Traceback (most recent call last):
+    ValueError: padding too large for body (max 2)
+    """
+    def __init__(self, *args, **kwargs):
+        self.body = kwargs.pop('body', 10)
+        self.tail = kwargs.pop('tail', 2)
+        self.align_left = kwargs.pop('align_left', False)
+        self.margin = kwargs.pop('margin', 4)  # TODO: make the default relative to body?
+        # validate padding value
+        max_padding = int(math.ceil(self.body/2.0)-1)
+        self.padding = kwargs.pop('padding', min(4, max_padding))
+        if self.padding > max_padding:
+            raise ValueError('padding too large for body (max %d)'%max_padding)
+        super(DiggPaginator, self).__init__(*args, **kwargs)
+
+    def page(self, number, *args, **kwargs):
+        """Return a standard ``Page`` instance with custom, digg-specific
+        page ranges attached.
+        """
+
+        page = super(DiggPaginator, self).page(number, *args, **kwargs)
+        number = int(number) # we know this will work
+
+        # easier access
+        num_pages, body, tail, padding, margin = \
+            self.num_pages, self.body, self.tail, self.padding, self.margin
+
+        # put active page in middle of main range
+        main_range = map(int, [
+            math.floor(number-body/2.0)+1,  # +1 = shift odd body to right
+            math.floor(number+body/2.0)])
+        # adjust bounds
+        if main_range[0] < 1:
+            main_range = map(abs(main_range[0]-1).__add__, main_range)
+        if main_range[1] > num_pages:
+            main_range = map((num_pages-main_range[1]).__add__, main_range)
+
+        # Determine leading and trailing ranges; if possible and appropriate,
+        # combine them with the main range, in which case the resulting main
+        # block might end up considerable larger than requested. While we
+        # can't guarantee the exact size in those cases, we can at least try
+        # to come as close as possible: we can reduce the other boundary to
+        # max padding, instead of using half the body size, which would
+        # otherwise be the case. If the padding is large enough, this will
+        # of course have no effect.
+        # Example:
+        #     total pages=100, page=4, body=5, (default padding=2)
+        #     1 2 3 [4] 5 6 ... 99 100
+        #     total pages=100, page=4, body=5, padding=1
+        #     1 2 3 [4] 5 ... 99 100
+        # If it were not for this adjustment, both cases would result in the
+        # first output, regardless of the padding value.
+        if main_range[0] <= tail+margin:
+            leading = []
+            main_range = [1, max(body, min(number+padding, main_range[1]))]
+            main_range[0] = 1
+        else:
+            leading = range(1, tail+1)
+        # basically same for trailing range, but not in ``left_align`` mode
+        if self.align_left:
+            trailing = []
+        else:
+            if main_range[1] >= num_pages-(tail+margin)+1:
+                trailing = []
+                if not leading:
+                    # ... but handle the special case of neither leading nor
+                    # trailing ranges; otherwise, we would now modify the
+                    # main range low bound, which we just set in the previous
+                    # section, again.
+                    main_range = [1, num_pages]
+                else:
+                    main_range = [min(num_pages-body+1, max(number-padding, main_range[0])), num_pages]
+            else:
+                trailing = range(num_pages-tail+1, num_pages+1)
+
+        # finally, normalize values that are out of bound; this basically
+        # fixes all the things the above code screwed up in the simple case
+        # of few enough pages where one range would suffice.
+        main_range = [max(main_range[0], 1), min(main_range[1], num_pages)]
+
+        # make the result of our calculations available as custom ranges
+        # on the ``Page`` instance.
+        page.main_range = range(main_range[0], main_range[1]+1)
+        page.leading_range = leading
+        page.trailing_range = trailing
+        page.page_range = reduce(lambda x, y: x+((x and y) and [False])+y,
+            [page.leading_range, page.main_range, page.trailing_range])
+
+        page.__class__ = DiggPage
+        return page
+
+class DiggPage(Page):
+    def __str__(self):
+        return " ... ".join(filter(None, [
+                            " ".join(map(str, self.leading_range)),
+                            " ".join(map(str, self.main_range)),
+                            " ".join(map(str, self.trailing_range))]))
+
+class QuerySetDiggPaginator(DiggPaginator, QuerySetPaginator):
+    pass
+
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()