1import collections.abc
2import inspect
3import warnings
4from math import ceil
5
6from django.utils.functional import cached_property
7from django.utils.inspect import method_has_no_args
8from django.utils.translation import gettext_lazy as _
9
10
11class UnorderedObjectListWarning(RuntimeWarning):
12 pass
13
14
15class InvalidPage(Exception):
16 pass
17
18
19class PageNotAnInteger(InvalidPage):
20 pass
21
22
23class EmptyPage(InvalidPage):
24 pass
25
26
27class Paginator:
28 # Translators: String used to replace omitted page numbers in elided page
29 # range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10].
30 ELLIPSIS = _("…")
31 default_error_messages = {
32 "invalid_page": _("That page number is not an integer"),
33 "min_page": _("That page number is less than 1"),
34 "no_results": _("That page contains no results"),
35 }
36
37 def __init__(
38 self,
39 object_list,
40 per_page,
41 orphans=0,
42 allow_empty_first_page=True,
43 error_messages=None,
44 ):
45 self.object_list = object_list
46 self._check_object_list_is_ordered()
47 self.per_page = int(per_page)
48 self.orphans = int(orphans)
49 self.allow_empty_first_page = allow_empty_first_page
50 self.error_messages = (
51 self.default_error_messages
52 if error_messages is None
53 else self.default_error_messages | error_messages
54 )
55
56 def __iter__(self):
57 for page_number in self.page_range:
58 yield self.page(page_number)
59
60 def validate_number(self, number):
61 """Validate the given 1-based page number."""
62 try:
63 if isinstance(number, float) and not number.is_integer():
64 raise ValueError
65 number = int(number)
66 except (TypeError, ValueError):
67 raise PageNotAnInteger(self.error_messages["invalid_page"])
68 if number < 1:
69 raise EmptyPage(self.error_messages["min_page"])
70 if number > self.num_pages:
71 raise EmptyPage(self.error_messages["no_results"])
72 return number
73
74 def get_page(self, number):
75 """
76 Return a valid page, even if the page argument isn't a number or isn't
77 in range.
78 """
79 try:
80 number = self.validate_number(number)
81 except PageNotAnInteger:
82 number = 1
83 except EmptyPage:
84 number = self.num_pages
85 return self.page(number)
86
87 def page(self, number):
88 """Return a Page object for the given 1-based page number."""
89 number = self.validate_number(number)
90 bottom = (number - 1) * self.per_page
91 top = bottom + self.per_page
92 if top + self.orphans >= self.count:
93 top = self.count
94 return self._get_page(self.object_list[bottom:top], number, self)
95
96 def _get_page(self, *args, **kwargs):
97 """
98 Return an instance of a single page.
99
100 This hook can be used by subclasses to use an alternative to the
101 standard :cls:`Page` object.
102 """
103 return Page(*args, **kwargs)
104
105 @cached_property
106 def count(self):
107 """Return the total number of objects, across all pages."""
108 c = getattr(self.object_list, "count", None)
109 if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
110 return c()
111 return len(self.object_list)
112
113 @cached_property
114 def num_pages(self):
115 """Return the total number of pages."""
116 if self.count == 0 and not self.allow_empty_first_page:
117 return 0
118 hits = max(1, self.count - self.orphans)
119 return ceil(hits / self.per_page)
120
121 @property
122 def page_range(self):
123 """
124 Return a 1-based range of pages for iterating through within
125 a template for loop.
126 """
127 return range(1, self.num_pages + 1)
128
129 def _check_object_list_is_ordered(self):
130 """
131 Warn if self.object_list is unordered (typically a QuerySet).
132 """
133 ordered = getattr(self.object_list, "ordered", None)
134 if ordered is not None and not ordered:
135 obj_list_repr = (
136 "{} {}".format(
137 self.object_list.model, self.object_list.__class__.__name__
138 )
139 if hasattr(self.object_list, "model")
140 else "{!r}".format(self.object_list)
141 )
142 warnings.warn(
143 "Pagination may yield inconsistent results with an unordered "
144 "object_list: {}.".format(obj_list_repr),
145 UnorderedObjectListWarning,
146 stacklevel=3,
147 )
148
149 def get_elided_page_range(self, number=1, *, on_each_side=3, on_ends=2):
150 """
151 Return a 1-based range of pages with some values elided.
152
153 If the page range is larger than a given size, the whole range is not
154 provided and a compact form is returned instead, e.g. for a paginator
155 with 50 pages, if page 43 were the current page, the output, with the
156 default arguments, would be:
157
158 1, 2, …, 40, 41, 42, 43, 44, 45, 46, …, 49, 50.
159 """
160 number = self.validate_number(number)
161
162 if self.num_pages <= (on_each_side + on_ends) * 2:
163 yield from self.page_range
164 return
165
166 if number > (1 + on_each_side + on_ends) + 1:
167 yield from range(1, on_ends + 1)
168 yield self.ELLIPSIS
169 yield from range(number - on_each_side, number + 1)
170 else:
171 yield from range(1, number + 1)
172
173 if number < (self.num_pages - on_each_side - on_ends) - 1:
174 yield from range(number + 1, number + on_each_side + 1)
175 yield self.ELLIPSIS
176 yield from range(self.num_pages - on_ends + 1, self.num_pages + 1)
177 else:
178 yield from range(number + 1, self.num_pages + 1)
179
180
181class Page(collections.abc.Sequence):
182 def __init__(self, object_list, number, paginator):
183 self.object_list = object_list
184 self.number = number
185 self.paginator = paginator
186
187 def __repr__(self):
188 return "<Page %s of %s>" % (self.number, self.paginator.num_pages)
189
190 def __len__(self):
191 return len(self.object_list)
192
193 def __getitem__(self, index):
194 if not isinstance(index, (int, slice)):
195 raise TypeError(
196 "Page indices must be integers or slices, not %s."
197 % type(index).__name__
198 )
199 # The object_list is converted to a list so that if it was a QuerySet
200 # it won't be a database hit per __getitem__.
201 if not isinstance(self.object_list, list):
202 self.object_list = list(self.object_list)
203 return self.object_list[index]
204
205 def has_next(self):
206 return self.number < self.paginator.num_pages
207
208 def has_previous(self):
209 return self.number > 1
210
211 def has_other_pages(self):
212 return self.has_previous() or self.has_next()
213
214 def next_page_number(self):
215 return self.paginator.validate_number(self.number + 1)
216
217 def previous_page_number(self):
218 return self.paginator.validate_number(self.number - 1)
219
220 def start_index(self):
221 """
222 Return the 1-based index of the first object on this page,
223 relative to total objects in the paginator.
224 """
225 # Special case, return zero if no items.
226 if self.paginator.count == 0:
227 return 0
228 return (self.paginator.per_page * (self.number - 1)) + 1
229
230 def end_index(self):
231 """
232 Return the 1-based index of the last object on this page,
233 relative to total objects found (hits).
234 """
235 # Special case for the last page because there can be orphans.
236 if self.number == self.paginator.num_pages:
237 return self.paginator.count
238 return self.number * self.paginator.per_page