annotate core/paginator.py @ 811:56b30c79f10e

Private messages refactor: start adding tests.
author Brian Neal <bgneal@gmail.com>
date Sun, 07 Sep 2014 13:12:19 -0500
parents ee87ea74d46b
children
rev   line source
gremmie@1 1 """
gremmie@1 2 Digg.com style paginator.
gremmie@1 3 References:
gremmie@1 4 http://www.djangosnippets.org/snippets/773/
gremmie@1 5 http://blog.elsdoerfer.name/2008/05/26/diggpaginator-update/
gremmie@1 6 http://blog.elsdoerfer.name/2008/03/06/yet-another-paginator-digg-style/
gremmie@1 7 """
gremmie@1 8 import math
gremmie@1 9 from django.core.paginator import \
gremmie@1 10 Paginator, QuerySetPaginator, Page, InvalidPage
gremmie@1 11
gremmie@1 12 __all__ = (
gremmie@1 13 'InvalidPage',
gremmie@1 14 'ExPaginator',
gremmie@1 15 'DiggPaginator',
gremmie@1 16 'QuerySetDiggPaginator',
gremmie@1 17 )
gremmie@1 18
gremmie@1 19 class ExPaginator(Paginator):
gremmie@1 20 """Adds a ``softlimit`` option to ``page()``. If True, querying a
gremmie@1 21 page number larger than max. will not fail, but instead return the
gremmie@1 22 last available page.
gremmie@1 23
gremmie@1 24 This is useful when the data source can not provide an exact count
gremmie@1 25 at all times (like some search engines), meaning the user could
gremmie@1 26 possibly see links to invalid pages at some point which we wouldn't
gremmie@1 27 want to fail as 404s.
gremmie@1 28
gremmie@1 29 >>> items = range(1, 1000)
gremmie@1 30 >>> paginator = ExPaginator(items, 10)
gremmie@1 31 >>> paginator.page(1000)
gremmie@1 32 Traceback (most recent call last):
gremmie@1 33 InvalidPage: That page contains no results
gremmie@1 34 >>> paginator.page(1000, softlimit=True)
gremmie@1 35 <Page 100 of 100>
gremmie@1 36
gremmie@1 37 # [bug] graceful handling of non-int args
gremmie@1 38 >>> paginator.page("str")
gremmie@1 39 Traceback (most recent call last):
gremmie@1 40 InvalidPage: That page number is not an integer
gremmie@1 41 """
gremmie@1 42 def _ensure_int(self, num, e):
gremmie@1 43 # see Django #7307
gremmie@1 44 try:
gremmie@1 45 return int(num)
gremmie@1 46 except ValueError:
gremmie@1 47 raise e
gremmie@1 48
gremmie@1 49 def page(self, number, softlimit=False):
gremmie@1 50 try:
gremmie@1 51 return super(ExPaginator, self).page(number)
gremmie@1 52 except InvalidPage, e:
gremmie@1 53 number = self._ensure_int(number, e)
gremmie@1 54 if number > self.num_pages and softlimit:
gremmie@1 55 return self.page(self.num_pages, softlimit=False)
gremmie@1 56 else:
gremmie@1 57 raise e
gremmie@1 58
gremmie@1 59 class DiggPaginator(ExPaginator):
gremmie@1 60 """
gremmie@1 61 Based on Django's default paginator, it adds "Digg-style" page ranges
gremmie@1 62 with a leading block of pages, an optional middle block, and another
gremmie@1 63 block at the end of the page range. They are available as attributes
gremmie@1 64 on the page:
gremmie@1 65
gremmie@1 66 {# with: page = digg_paginator.page(1) #}
gremmie@1 67 {% for num in page.leading_range %} ...
gremmie@1 68 {% for num in page.main_range %} ...
gremmie@1 69 {% for num in page.trailing_range %} ...
gremmie@1 70
gremmie@1 71 Additionally, ``page_range`` contains a nun-numeric ``False`` element
gremmie@1 72 for every transition between two ranges.
gremmie@1 73
gremmie@1 74 {% for num in page.page_range %}
gremmie@1 75 {% if not num %} ... {# literally output dots #}
gremmie@1 76 {% else %}{{ num }}
gremmie@1 77 {% endif %}
gremmie@1 78 {% endfor %}
gremmie@1 79
gremmie@1 80 Additional arguments passed to the constructor allow customization of
gremmie@1 81 how those bocks are constructed:
gremmie@1 82
gremmie@1 83 body=5, tail=2
gremmie@1 84
gremmie@1 85 [1] 2 3 4 5 ... 91 92
gremmie@1 86 |_________| |___|
gremmie@1 87 body tail
gremmie@1 88 |_____|
gremmie@1 89 margin
gremmie@1 90
gremmie@1 91 body=5, tail=2, padding=2
gremmie@1 92
gremmie@1 93 1 2 ... 6 7 [8] 9 10 ... 91 92
gremmie@1 94 |_| |__|
gremmie@1 95 ^padding^
gremmie@1 96 |_| |__________| |___|
gremmie@1 97 tail body tail
gremmie@1 98
gremmie@1 99 ``margin`` is the minimum number of pages required between two ranges; if
gremmie@1 100 there are less, they are combined into one.
gremmie@1 101
gremmie@1 102 When ``align_left`` is set to ``True``, the paginator operates in a
gremmie@1 103 special mode that always skips the right tail, e.g. does not display the
gremmie@1 104 end block unless necessary. This is useful for situations in which the
gremmie@1 105 exact number of items/pages is not actually known.
gremmie@1 106
gremmie@1 107 # odd body length
gremmie@1 108 >>> print DiggPaginator(range(1,1000), 10, body=5).page(1)
gremmie@1 109 1 2 3 4 5 ... 99 100
gremmie@1 110 >>> print DiggPaginator(range(1,1000), 10, body=5).page(100)
gremmie@1 111 1 2 ... 96 97 98 99 100
gremmie@1 112
gremmie@1 113 # even body length
gremmie@1 114 >>> print DiggPaginator(range(1,1000), 10, body=6).page(1)
gremmie@1 115 1 2 3 4 5 6 ... 99 100
gremmie@1 116 >>> print DiggPaginator(range(1,1000), 10, body=6).page(100)
gremmie@1 117 1 2 ... 95 96 97 98 99 100
gremmie@1 118
gremmie@1 119 # leading range and main range are combined when close; note how
gremmie@1 120 # we have varying body and padding values, and their effect.
gremmie@1 121 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2).page(3)
gremmie@1 122 1 2 3 4 5 ... 99 100
gremmie@1 123 >>> print DiggPaginator(range(1,1000), 10, body=6, padding=2, margin=2).page(4)
gremmie@1 124 1 2 3 4 5 6 ... 99 100
gremmie@1 125 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2).page(6)
gremmie@1 126 1 2 3 4 5 6 7 ... 99 100
gremmie@1 127 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2).page(7)
gremmie@1 128 1 2 ... 5 6 7 8 9 ... 99 100
gremmie@1 129 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2).page(7)
gremmie@1 130 1 2 ... 5 6 7 8 9 ... 99 100
gremmie@1 131
gremmie@1 132 # the trailing range works the same
gremmie@1 133 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2, ).page(98)
gremmie@1 134 1 2 ... 96 97 98 99 100
gremmie@1 135 >>> print DiggPaginator(range(1,1000), 10, body=6, padding=2, margin=2, ).page(97)
gremmie@1 136 1 2 ... 95 96 97 98 99 100
gremmie@1 137 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2, ).page(95)
gremmie@1 138 1 2 ... 94 95 96 97 98 99 100
gremmie@1 139 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=2, margin=2, ).page(94)
gremmie@1 140 1 2 ... 92 93 94 95 96 ... 99 100
gremmie@1 141 >>> print DiggPaginator(range(1,1000), 10, body=5, padding=1, margin=2, ).page(94)
gremmie@1 142 1 2 ... 92 93 94 95 96 ... 99 100
gremmie@1 143
gremmie@1 144 # all three ranges may be combined as well
gremmie@1 145 >>> print DiggPaginator(range(1,151), 10, body=6, padding=2).page(7)
gremmie@1 146 1 2 3 4 5 6 7 8 9 ... 14 15
gremmie@1 147 >>> print DiggPaginator(range(1,151), 10, body=6, padding=2).page(8)
gremmie@1 148 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
gremmie@1 149 >>> print DiggPaginator(range(1,151), 10, body=6, padding=1).page(8)
gremmie@1 150 1 2 3 4 5 6 7 8 9 ... 14 15
gremmie@1 151
gremmie@1 152 # no leading or trailing ranges might be required if there are only
gremmie@1 153 # a very small number of pages
gremmie@1 154 >>> print DiggPaginator(range(1,80), 10, body=10).page(1)
gremmie@1 155 1 2 3 4 5 6 7 8
gremmie@1 156 >>> print DiggPaginator(range(1,80), 10, body=10).page(8)
gremmie@1 157 1 2 3 4 5 6 7 8
gremmie@1 158 >>> print DiggPaginator(range(1,12), 10, body=5).page(1)
gremmie@1 159 1 2
gremmie@1 160
gremmie@1 161 # test left align mode
gremmie@1 162 >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(1)
gremmie@1 163 1 2 3 4 5
gremmie@1 164 >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(50)
gremmie@1 165 1 2 ... 48 49 50 51 52
gremmie@1 166 >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(97)
gremmie@1 167 1 2 ... 95 96 97 98 99
gremmie@1 168 >>> print DiggPaginator(range(1,1000), 10, body=5, align_left=True).page(100)
gremmie@1 169 1 2 ... 96 97 98 99 100
gremmie@1 170
gremmie@1 171 # padding: default value
gremmie@1 172 >>> DiggPaginator(range(1,1000), 10, body=10).padding
gremmie@1 173 4
gremmie@1 174
gremmie@1 175 # padding: automatic reduction
gremmie@1 176 >>> DiggPaginator(range(1,1000), 10, body=5).padding
gremmie@1 177 2
gremmie@1 178 >>> DiggPaginator(range(1,1000), 10, body=6).padding
gremmie@1 179 2
gremmie@1 180
gremmie@1 181 # padding: sanity check
gremmie@1 182 >>> DiggPaginator(range(1,1000), 10, body=5, padding=3)
gremmie@1 183 Traceback (most recent call last):
gremmie@1 184 ValueError: padding too large for body (max 2)
gremmie@1 185 """
gremmie@1 186 def __init__(self, *args, **kwargs):
gremmie@1 187 self.body = kwargs.pop('body', 10)
gremmie@1 188 self.tail = kwargs.pop('tail', 2)
gremmie@1 189 self.align_left = kwargs.pop('align_left', False)
gremmie@1 190 self.margin = kwargs.pop('margin', 4) # TODO: make the default relative to body?
gremmie@1 191 # validate padding value
gremmie@1 192 max_padding = int(math.ceil(self.body/2.0)-1)
gremmie@1 193 self.padding = kwargs.pop('padding', min(4, max_padding))
gremmie@1 194 if self.padding > max_padding:
gremmie@1 195 raise ValueError('padding too large for body (max %d)'%max_padding)
gremmie@1 196 super(DiggPaginator, self).__init__(*args, **kwargs)
gremmie@1 197
gremmie@1 198 def page(self, number, *args, **kwargs):
gremmie@1 199 """Return a standard ``Page`` instance with custom, digg-specific
gremmie@1 200 page ranges attached.
gremmie@1 201 """
gremmie@1 202
gremmie@1 203 page = super(DiggPaginator, self).page(number, *args, **kwargs)
gremmie@1 204 number = int(number) # we know this will work
gremmie@1 205
gremmie@1 206 # easier access
gremmie@1 207 num_pages, body, tail, padding, margin = \
gremmie@1 208 self.num_pages, self.body, self.tail, self.padding, self.margin
gremmie@1 209
gremmie@1 210 # put active page in middle of main range
gremmie@1 211 main_range = map(int, [
gremmie@1 212 math.floor(number-body/2.0)+1, # +1 = shift odd body to right
gremmie@1 213 math.floor(number+body/2.0)])
gremmie@1 214 # adjust bounds
gremmie@1 215 if main_range[0] < 1:
gremmie@1 216 main_range = map(abs(main_range[0]-1).__add__, main_range)
gremmie@1 217 if main_range[1] > num_pages:
gremmie@1 218 main_range = map((num_pages-main_range[1]).__add__, main_range)
gremmie@1 219
gremmie@1 220 # Determine leading and trailing ranges; if possible and appropriate,
gremmie@1 221 # combine them with the main range, in which case the resulting main
gremmie@1 222 # block might end up considerable larger than requested. While we
gremmie@1 223 # can't guarantee the exact size in those cases, we can at least try
gremmie@1 224 # to come as close as possible: we can reduce the other boundary to
gremmie@1 225 # max padding, instead of using half the body size, which would
gremmie@1 226 # otherwise be the case. If the padding is large enough, this will
gremmie@1 227 # of course have no effect.
gremmie@1 228 # Example:
gremmie@1 229 # total pages=100, page=4, body=5, (default padding=2)
gremmie@1 230 # 1 2 3 [4] 5 6 ... 99 100
gremmie@1 231 # total pages=100, page=4, body=5, padding=1
gremmie@1 232 # 1 2 3 [4] 5 ... 99 100
gremmie@1 233 # If it were not for this adjustment, both cases would result in the
gremmie@1 234 # first output, regardless of the padding value.
gremmie@1 235 if main_range[0] <= tail+margin:
gremmie@1 236 leading = []
gremmie@1 237 main_range = [1, max(body, min(number+padding, main_range[1]))]
gremmie@1 238 main_range[0] = 1
gremmie@1 239 else:
gremmie@1 240 leading = range(1, tail+1)
gremmie@1 241 # basically same for trailing range, but not in ``left_align`` mode
gremmie@1 242 if self.align_left:
gremmie@1 243 trailing = []
gremmie@1 244 else:
gremmie@1 245 if main_range[1] >= num_pages-(tail+margin)+1:
gremmie@1 246 trailing = []
gremmie@1 247 if not leading:
gremmie@1 248 # ... but handle the special case of neither leading nor
gremmie@1 249 # trailing ranges; otherwise, we would now modify the
gremmie@1 250 # main range low bound, which we just set in the previous
gremmie@1 251 # section, again.
gremmie@1 252 main_range = [1, num_pages]
gremmie@1 253 else:
gremmie@1 254 main_range = [min(num_pages-body+1, max(number-padding, main_range[0])), num_pages]
gremmie@1 255 else:
gremmie@1 256 trailing = range(num_pages-tail+1, num_pages+1)
gremmie@1 257
gremmie@1 258 # finally, normalize values that are out of bound; this basically
gremmie@1 259 # fixes all the things the above code screwed up in the simple case
gremmie@1 260 # of few enough pages where one range would suffice.
gremmie@1 261 main_range = [max(main_range[0], 1), min(main_range[1], num_pages)]
gremmie@1 262
gremmie@1 263 # make the result of our calculations available as custom ranges
gremmie@1 264 # on the ``Page`` instance.
gremmie@1 265 page.main_range = range(main_range[0], main_range[1]+1)
gremmie@1 266 page.leading_range = leading
gremmie@1 267 page.trailing_range = trailing
gremmie@1 268 page.page_range = reduce(lambda x, y: x+((x and y) and [False])+y,
gremmie@1 269 [page.leading_range, page.main_range, page.trailing_range])
gremmie@1 270
gremmie@1 271 page.__class__ = DiggPage
gremmie@1 272 return page
gremmie@1 273
gremmie@1 274 class DiggPage(Page):
gremmie@1 275 def __str__(self):
gremmie@1 276 return " ... ".join(filter(None, [
gremmie@1 277 " ".join(map(str, self.leading_range)),
gremmie@1 278 " ".join(map(str, self.main_range)),
gremmie@1 279 " ".join(map(str, self.trailing_range))]))
gremmie@1 280
gremmie@1 281 class QuerySetDiggPaginator(DiggPaginator, QuerySetPaginator):
gremmie@1 282 pass
gremmie@1 283
gremmie@1 284 if __name__ == "__main__":
gremmie@1 285 import doctest
gremmie@1 286 doctest.testmod()