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: 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()