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