Coverage for /var/srv/projects/api.amasfac.comuna18.com/tmp/venv/lib/python3.9/site-packages/rest_framework/pagination.py: 22%
430 statements
« prev ^ index » next coverage.py v6.4.4, created at 2023-07-17 14:22 -0600
« prev ^ index » next coverage.py v6.4.4, created at 2023-07-17 14:22 -0600
1"""
2Pagination serializers determine the structure of the output that should
3be used for paginated responses.
4"""
5from base64 import b64decode, b64encode
6from collections import OrderedDict, namedtuple
7from urllib import parse
9from django.core.paginator import InvalidPage
10from django.core.paginator import Paginator as DjangoPaginator
11from django.template import loader
12from django.utils.encoding import force_str
13from django.utils.translation import gettext_lazy as _
15from rest_framework.compat import coreapi, coreschema
16from rest_framework.exceptions import NotFound
17from rest_framework.response import Response
18from rest_framework.settings import api_settings
19from rest_framework.utils.urls import remove_query_param, replace_query_param
22def _positive_int(integer_string, strict=False, cutoff=None):
23 """
24 Cast a string to a strictly positive integer.
25 """
26 ret = int(integer_string)
27 if ret < 0 or (ret == 0 and strict):
28 raise ValueError()
29 if cutoff:
30 return min(ret, cutoff)
31 return ret
34def _divide_with_ceil(a, b):
35 """
36 Returns 'a' divided by 'b', with any remainder rounded up.
37 """
38 if a % b:
39 return (a // b) + 1
41 return a // b
44def _get_displayed_page_numbers(current, final):
45 """
46 This utility function determines a list of page numbers to display.
47 This gives us a nice contextually relevant set of page numbers.
49 For example:
50 current=14, final=16 -> [1, None, 13, 14, 15, 16]
52 This implementation gives one page to each side of the cursor,
53 or two pages to the side when the cursor is at the edge, then
54 ensures that any breaks between non-continuous page numbers never
55 remove only a single page.
57 For an alternative implementation which gives two pages to each side of
58 the cursor, eg. as in GitHub issue list pagination, see:
60 https://gist.github.com/tomchristie/321140cebb1c4a558b15
61 """
62 assert current >= 1
63 assert final >= current
65 if final <= 5:
66 return list(range(1, final + 1))
68 # We always include the first two pages, last two pages, and
69 # two pages either side of the current page.
70 included = {1, current - 1, current, current + 1, final}
72 # If the break would only exclude a single page number then we
73 # may as well include the page number instead of the break.
74 if current <= 4:
75 included.add(2)
76 included.add(3)
77 if current >= final - 3:
78 included.add(final - 1)
79 included.add(final - 2)
81 # Now sort the page numbers and drop anything outside the limits.
82 included = [
83 idx for idx in sorted(included)
84 if 0 < idx <= final
85 ]
87 # Finally insert any `...` breaks
88 if current > 4:
89 included.insert(1, None)
90 if current < final - 3:
91 included.insert(len(included) - 1, None)
92 return included
95def _get_page_links(page_numbers, current, url_func):
96 """
97 Given a list of page numbers and `None` page breaks,
98 return a list of `PageLink` objects.
99 """
100 page_links = []
101 for page_number in page_numbers:
102 if page_number is None:
103 page_link = PAGE_BREAK
104 else:
105 page_link = PageLink(
106 url=url_func(page_number),
107 number=page_number,
108 is_active=(page_number == current),
109 is_break=False
110 )
111 page_links.append(page_link)
112 return page_links
115def _reverse_ordering(ordering_tuple):
116 """
117 Given an order_by tuple such as `('-created', 'uuid')` reverse the
118 ordering and return a new tuple, eg. `('created', '-uuid')`.
119 """
120 def invert(x):
121 return x[1:] if x.startswith('-') else '-' + x
123 return tuple([invert(item) for item in ordering_tuple])
126Cursor = namedtuple('Cursor', ['offset', 'reverse', 'position'])
127PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break'])
129PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True)
132class BasePagination:
133 display_page_controls = False
135 def paginate_queryset(self, queryset, request, view=None): # pragma: no cover
136 raise NotImplementedError('paginate_queryset() must be implemented.')
138 def get_paginated_response(self, data): # pragma: no cover
139 raise NotImplementedError('get_paginated_response() must be implemented.')
141 def get_paginated_response_schema(self, schema):
142 return schema
144 def to_html(self): # pragma: no cover
145 raise NotImplementedError('to_html() must be implemented to display page controls.')
147 def get_results(self, data):
148 return data['results']
150 def get_schema_fields(self, view):
151 assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
152 return []
154 def get_schema_operation_parameters(self, view):
155 return []
158class PageNumberPagination(BasePagination):
159 """
160 A simple page number based style that supports page numbers as
161 query parameters. For example:
163 http://api.example.org/accounts/?page=4
164 http://api.example.org/accounts/?page=4&page_size=100
165 """
166 # The default page size.
167 # Defaults to `None`, meaning pagination is disabled.
168 page_size = api_settings.PAGE_SIZE
170 django_paginator_class = DjangoPaginator
172 # Client can control the page using this query parameter.
173 page_query_param = 'page'
174 page_query_description = _('A page number within the paginated result set.')
176 # Client can control the page size using this query parameter.
177 # Default is 'None'. Set to eg 'page_size' to enable usage.
178 page_size_query_param = None
179 page_size_query_description = _('Number of results to return per page.')
181 # Set to an integer to limit the maximum page size the client may request.
182 # Only relevant if 'page_size_query_param' has also been set.
183 max_page_size = None
185 last_page_strings = ('last',)
187 template = 'rest_framework/pagination/numbers.html'
189 invalid_page_message = _('Invalid page.')
191 def paginate_queryset(self, queryset, request, view=None):
192 """
193 Paginate a queryset if required, either returning a
194 page object, or `None` if pagination is not configured for this view.
195 """
196 page_size = self.get_page_size(request)
197 if not page_size: 197 ↛ 198line 197 didn't jump to line 198, because the condition on line 197 was never true
198 return None
200 paginator = self.django_paginator_class(queryset, page_size)
201 page_number = self.get_page_number(request, paginator)
203 try:
204 self.page = paginator.page(page_number)
205 except InvalidPage as exc:
206 msg = self.invalid_page_message.format(
207 page_number=page_number, message=str(exc)
208 )
209 raise NotFound(msg)
211 if paginator.num_pages > 1 and self.template is not None: 211 ↛ 213line 211 didn't jump to line 213, because the condition on line 211 was never true
212 # The browsable API should display pagination controls.
213 self.display_page_controls = True
215 self.request = request
216 return list(self.page)
218 def get_page_number(self, request, paginator):
219 page_number = request.query_params.get(self.page_query_param, 1)
220 if page_number in self.last_page_strings: 220 ↛ 221line 220 didn't jump to line 221, because the condition on line 220 was never true
221 page_number = paginator.num_pages
222 return page_number
224 def get_paginated_response(self, data):
225 return Response(OrderedDict([
226 ('count', self.page.paginator.count),
227 ('next', self.get_next_link()),
228 ('previous', self.get_previous_link()),
229 ('results', data)
230 ]))
232 def get_paginated_response_schema(self, schema):
233 return {
234 'type': 'object',
235 'properties': {
236 'count': {
237 'type': 'integer',
238 'example': 123,
239 },
240 'next': {
241 'type': 'string',
242 'nullable': True,
243 'format': 'uri',
244 'example': 'http://api.example.org/accounts/?{page_query_param}=4'.format(
245 page_query_param=self.page_query_param)
246 },
247 'previous': {
248 'type': 'string',
249 'nullable': True,
250 'format': 'uri',
251 'example': 'http://api.example.org/accounts/?{page_query_param}=2'.format(
252 page_query_param=self.page_query_param)
253 },
254 'results': schema,
255 },
256 }
258 def get_page_size(self, request):
259 if self.page_size_query_param: 259 ↛ 269line 259 didn't jump to line 269, because the condition on line 259 was never false
260 try:
261 return _positive_int(
262 request.query_params[self.page_size_query_param],
263 strict=True,
264 cutoff=self.max_page_size
265 )
266 except (KeyError, ValueError):
267 pass
269 return self.page_size
271 def get_next_link(self):
272 if not self.page.has_next():
273 return None
274 url = self.request.build_absolute_uri()
275 page_number = self.page.next_page_number()
276 return replace_query_param(url, self.page_query_param, page_number)
278 def get_previous_link(self):
279 if not self.page.has_previous():
280 return None
281 url = self.request.build_absolute_uri()
282 page_number = self.page.previous_page_number()
283 if page_number == 1:
284 return remove_query_param(url, self.page_query_param)
285 return replace_query_param(url, self.page_query_param, page_number)
287 def get_html_context(self):
288 base_url = self.request.build_absolute_uri()
290 def page_number_to_url(page_number):
291 if page_number == 1:
292 return remove_query_param(base_url, self.page_query_param)
293 else:
294 return replace_query_param(base_url, self.page_query_param, page_number)
296 current = self.page.number
297 final = self.page.paginator.num_pages
298 page_numbers = _get_displayed_page_numbers(current, final)
299 page_links = _get_page_links(page_numbers, current, page_number_to_url)
301 return {
302 'previous_url': self.get_previous_link(),
303 'next_url': self.get_next_link(),
304 'page_links': page_links
305 }
307 def to_html(self):
308 template = loader.get_template(self.template)
309 context = self.get_html_context()
310 return template.render(context)
312 def get_schema_fields(self, view):
313 assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
314 assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
315 fields = [
316 coreapi.Field(
317 name=self.page_query_param,
318 required=False,
319 location='query',
320 schema=coreschema.Integer(
321 title='Page',
322 description=force_str(self.page_query_description)
323 )
324 )
325 ]
326 if self.page_size_query_param is not None:
327 fields.append(
328 coreapi.Field(
329 name=self.page_size_query_param,
330 required=False,
331 location='query',
332 schema=coreschema.Integer(
333 title='Page size',
334 description=force_str(self.page_size_query_description)
335 )
336 )
337 )
338 return fields
340 def get_schema_operation_parameters(self, view):
341 parameters = [
342 {
343 'name': self.page_query_param,
344 'required': False,
345 'in': 'query',
346 'description': force_str(self.page_query_description),
347 'schema': {
348 'type': 'integer',
349 },
350 },
351 ]
352 if self.page_size_query_param is not None:
353 parameters.append(
354 {
355 'name': self.page_size_query_param,
356 'required': False,
357 'in': 'query',
358 'description': force_str(self.page_size_query_description),
359 'schema': {
360 'type': 'integer',
361 },
362 },
363 )
364 return parameters
367class LimitOffsetPagination(BasePagination):
368 """
369 A limit/offset based style. For example:
371 http://api.example.org/accounts/?limit=100
372 http://api.example.org/accounts/?offset=400&limit=100
373 """
374 default_limit = api_settings.PAGE_SIZE
375 limit_query_param = 'limit'
376 limit_query_description = _('Number of results to return per page.')
377 offset_query_param = 'offset'
378 offset_query_description = _('The initial index from which to return the results.')
379 max_limit = None
380 template = 'rest_framework/pagination/numbers.html'
382 def paginate_queryset(self, queryset, request, view=None):
383 self.limit = self.get_limit(request)
384 if self.limit is None:
385 return None
387 self.count = self.get_count(queryset)
388 self.offset = self.get_offset(request)
389 self.request = request
390 if self.count > self.limit and self.template is not None:
391 self.display_page_controls = True
393 if self.count == 0 or self.offset > self.count:
394 return []
395 return list(queryset[self.offset:self.offset + self.limit])
397 def get_paginated_response(self, data):
398 return Response(OrderedDict([
399 ('count', self.count),
400 ('next', self.get_next_link()),
401 ('previous', self.get_previous_link()),
402 ('results', data)
403 ]))
405 def get_paginated_response_schema(self, schema):
406 return {
407 'type': 'object',
408 'properties': {
409 'count': {
410 'type': 'integer',
411 'example': 123,
412 },
413 'next': {
414 'type': 'string',
415 'nullable': True,
416 'format': 'uri',
417 'example': 'http://api.example.org/accounts/?{offset_param}=400&{limit_param}=100'.format(
418 offset_param=self.offset_query_param, limit_param=self.limit_query_param),
419 },
420 'previous': {
421 'type': 'string',
422 'nullable': True,
423 'format': 'uri',
424 'example': 'http://api.example.org/accounts/?{offset_param}=200&{limit_param}=100'.format(
425 offset_param=self.offset_query_param, limit_param=self.limit_query_param),
426 },
427 'results': schema,
428 },
429 }
431 def get_limit(self, request):
432 if self.limit_query_param:
433 try:
434 return _positive_int(
435 request.query_params[self.limit_query_param],
436 strict=True,
437 cutoff=self.max_limit
438 )
439 except (KeyError, ValueError):
440 pass
442 return self.default_limit
444 def get_offset(self, request):
445 try:
446 return _positive_int(
447 request.query_params[self.offset_query_param],
448 )
449 except (KeyError, ValueError):
450 return 0
452 def get_next_link(self):
453 if self.offset + self.limit >= self.count:
454 return None
456 url = self.request.build_absolute_uri()
457 url = replace_query_param(url, self.limit_query_param, self.limit)
459 offset = self.offset + self.limit
460 return replace_query_param(url, self.offset_query_param, offset)
462 def get_previous_link(self):
463 if self.offset <= 0:
464 return None
466 url = self.request.build_absolute_uri()
467 url = replace_query_param(url, self.limit_query_param, self.limit)
469 if self.offset - self.limit <= 0:
470 return remove_query_param(url, self.offset_query_param)
472 offset = self.offset - self.limit
473 return replace_query_param(url, self.offset_query_param, offset)
475 def get_html_context(self):
476 base_url = self.request.build_absolute_uri()
478 if self.limit:
479 current = _divide_with_ceil(self.offset, self.limit) + 1
481 # The number of pages is a little bit fiddly.
482 # We need to sum both the number of pages from current offset to end
483 # plus the number of pages up to the current offset.
484 # When offset is not strictly divisible by the limit then we may
485 # end up introducing an extra page as an artifact.
486 final = (
487 _divide_with_ceil(self.count - self.offset, self.limit) +
488 _divide_with_ceil(self.offset, self.limit)
489 )
491 final = max(final, 1)
492 else:
493 current = 1
494 final = 1
496 if current > final:
497 current = final
499 def page_number_to_url(page_number):
500 if page_number == 1:
501 return remove_query_param(base_url, self.offset_query_param)
502 else:
503 offset = self.offset + ((page_number - current) * self.limit)
504 return replace_query_param(base_url, self.offset_query_param, offset)
506 page_numbers = _get_displayed_page_numbers(current, final)
507 page_links = _get_page_links(page_numbers, current, page_number_to_url)
509 return {
510 'previous_url': self.get_previous_link(),
511 'next_url': self.get_next_link(),
512 'page_links': page_links
513 }
515 def to_html(self):
516 template = loader.get_template(self.template)
517 context = self.get_html_context()
518 return template.render(context)
520 def get_count(self, queryset):
521 """
522 Determine an object count, supporting either querysets or regular lists.
523 """
524 try:
525 return queryset.count()
526 except (AttributeError, TypeError):
527 return len(queryset)
529 def get_schema_fields(self, view):
530 assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
531 assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
532 return [
533 coreapi.Field(
534 name=self.limit_query_param,
535 required=False,
536 location='query',
537 schema=coreschema.Integer(
538 title='Limit',
539 description=force_str(self.limit_query_description)
540 )
541 ),
542 coreapi.Field(
543 name=self.offset_query_param,
544 required=False,
545 location='query',
546 schema=coreschema.Integer(
547 title='Offset',
548 description=force_str(self.offset_query_description)
549 )
550 )
551 ]
553 def get_schema_operation_parameters(self, view):
554 parameters = [
555 {
556 'name': self.limit_query_param,
557 'required': False,
558 'in': 'query',
559 'description': force_str(self.limit_query_description),
560 'schema': {
561 'type': 'integer',
562 },
563 },
564 {
565 'name': self.offset_query_param,
566 'required': False,
567 'in': 'query',
568 'description': force_str(self.offset_query_description),
569 'schema': {
570 'type': 'integer',
571 },
572 },
573 ]
574 return parameters
577class CursorPagination(BasePagination):
578 """
579 The cursor pagination implementation is necessarily complex.
580 For an overview of the position/offset style we use, see this post:
581 https://cra.mr/2011/03/08/building-cursors-for-the-disqus-api
582 """
583 cursor_query_param = 'cursor'
584 cursor_query_description = _('The pagination cursor value.')
585 page_size = api_settings.PAGE_SIZE
586 invalid_cursor_message = _('Invalid cursor')
587 ordering = '-created'
588 template = 'rest_framework/pagination/previous_and_next.html'
590 # Client can control the page size using this query parameter.
591 # Default is 'None'. Set to eg 'page_size' to enable usage.
592 page_size_query_param = None
593 page_size_query_description = _('Number of results to return per page.')
595 # Set to an integer to limit the maximum page size the client may request.
596 # Only relevant if 'page_size_query_param' has also been set.
597 max_page_size = None
599 # The offset in the cursor is used in situations where we have a
600 # nearly-unique index. (Eg millisecond precision creation timestamps)
601 # We guard against malicious users attempting to cause expensive database
602 # queries, by having a hard cap on the maximum possible size of the offset.
603 offset_cutoff = 1000
605 def paginate_queryset(self, queryset, request, view=None):
606 self.page_size = self.get_page_size(request)
607 if not self.page_size:
608 return None
610 self.base_url = request.build_absolute_uri()
611 self.ordering = self.get_ordering(request, queryset, view)
613 self.cursor = self.decode_cursor(request)
614 if self.cursor is None:
615 (offset, reverse, current_position) = (0, False, None)
616 else:
617 (offset, reverse, current_position) = self.cursor
619 # Cursor pagination always enforces an ordering.
620 if reverse:
621 queryset = queryset.order_by(*_reverse_ordering(self.ordering))
622 else:
623 queryset = queryset.order_by(*self.ordering)
625 # If we have a cursor with a fixed position then filter by that.
626 if current_position is not None:
627 order = self.ordering[0]
628 is_reversed = order.startswith('-')
629 order_attr = order.lstrip('-')
631 # Test for: (cursor reversed) XOR (queryset reversed)
632 if self.cursor.reverse != is_reversed:
633 kwargs = {order_attr + '__lt': current_position}
634 else:
635 kwargs = {order_attr + '__gt': current_position}
637 queryset = queryset.filter(**kwargs)
639 # If we have an offset cursor then offset the entire page by that amount.
640 # We also always fetch an extra item in order to determine if there is a
641 # page following on from this one.
642 results = list(queryset[offset:offset + self.page_size + 1])
643 self.page = list(results[:self.page_size])
645 # Determine the position of the final item following the page.
646 if len(results) > len(self.page):
647 has_following_position = True
648 following_position = self._get_position_from_instance(results[-1], self.ordering)
649 else:
650 has_following_position = False
651 following_position = None
653 if reverse:
654 # If we have a reverse queryset, then the query ordering was in reverse
655 # so we need to reverse the items again before returning them to the user.
656 self.page = list(reversed(self.page))
658 # Determine next and previous positions for reverse cursors.
659 self.has_next = (current_position is not None) or (offset > 0)
660 self.has_previous = has_following_position
661 if self.has_next:
662 self.next_position = current_position
663 if self.has_previous:
664 self.previous_position = following_position
665 else:
666 # Determine next and previous positions for forward cursors.
667 self.has_next = has_following_position
668 self.has_previous = (current_position is not None) or (offset > 0)
669 if self.has_next:
670 self.next_position = following_position
671 if self.has_previous:
672 self.previous_position = current_position
674 # Display page controls in the browsable API if there is more
675 # than one page.
676 if (self.has_previous or self.has_next) and self.template is not None:
677 self.display_page_controls = True
679 return self.page
681 def get_page_size(self, request):
682 if self.page_size_query_param:
683 try:
684 return _positive_int(
685 request.query_params[self.page_size_query_param],
686 strict=True,
687 cutoff=self.max_page_size
688 )
689 except (KeyError, ValueError):
690 pass
692 return self.page_size
694 def get_next_link(self):
695 if not self.has_next:
696 return None
698 if self.page and self.cursor and self.cursor.reverse and self.cursor.offset != 0:
699 # If we're reversing direction and we have an offset cursor
700 # then we cannot use the first position we find as a marker.
701 compare = self._get_position_from_instance(self.page[-1], self.ordering)
702 else:
703 compare = self.next_position
704 offset = 0
706 has_item_with_unique_position = False
707 for item in reversed(self.page):
708 position = self._get_position_from_instance(item, self.ordering)
709 if position != compare:
710 # The item in this position and the item following it
711 # have different positions. We can use this position as
712 # our marker.
713 has_item_with_unique_position = True
714 break
716 # The item in this position has the same position as the item
717 # following it, we can't use it as a marker position, so increment
718 # the offset and keep seeking to the previous item.
719 compare = position
720 offset += 1
722 if self.page and not has_item_with_unique_position:
723 # There were no unique positions in the page.
724 if not self.has_previous:
725 # We are on the first page.
726 # Our cursor will have an offset equal to the page size,
727 # but no position to filter against yet.
728 offset = self.page_size
729 position = None
730 elif self.cursor.reverse:
731 # The change in direction will introduce a paging artifact,
732 # where we end up skipping forward a few extra items.
733 offset = 0
734 position = self.previous_position
735 else:
736 # Use the position from the existing cursor and increment
737 # it's offset by the page size.
738 offset = self.cursor.offset + self.page_size
739 position = self.previous_position
741 if not self.page:
742 position = self.next_position
744 cursor = Cursor(offset=offset, reverse=False, position=position)
745 return self.encode_cursor(cursor)
747 def get_previous_link(self):
748 if not self.has_previous:
749 return None
751 if self.page and self.cursor and not self.cursor.reverse and self.cursor.offset != 0:
752 # If we're reversing direction and we have an offset cursor
753 # then we cannot use the first position we find as a marker.
754 compare = self._get_position_from_instance(self.page[0], self.ordering)
755 else:
756 compare = self.previous_position
757 offset = 0
759 has_item_with_unique_position = False
760 for item in self.page:
761 position = self._get_position_from_instance(item, self.ordering)
762 if position != compare:
763 # The item in this position and the item following it
764 # have different positions. We can use this position as
765 # our marker.
766 has_item_with_unique_position = True
767 break
769 # The item in this position has the same position as the item
770 # following it, we can't use it as a marker position, so increment
771 # the offset and keep seeking to the previous item.
772 compare = position
773 offset += 1
775 if self.page and not has_item_with_unique_position:
776 # There were no unique positions in the page.
777 if not self.has_next:
778 # We are on the final page.
779 # Our cursor will have an offset equal to the page size,
780 # but no position to filter against yet.
781 offset = self.page_size
782 position = None
783 elif self.cursor.reverse:
784 # Use the position from the existing cursor and increment
785 # it's offset by the page size.
786 offset = self.cursor.offset + self.page_size
787 position = self.next_position
788 else:
789 # The change in direction will introduce a paging artifact,
790 # where we end up skipping back a few extra items.
791 offset = 0
792 position = self.next_position
794 if not self.page:
795 position = self.previous_position
797 cursor = Cursor(offset=offset, reverse=True, position=position)
798 return self.encode_cursor(cursor)
800 def get_ordering(self, request, queryset, view):
801 """
802 Return a tuple of strings, that may be used in an `order_by` method.
803 """
804 ordering_filters = [
805 filter_cls for filter_cls in getattr(view, 'filter_backends', [])
806 if hasattr(filter_cls, 'get_ordering')
807 ]
809 if ordering_filters:
810 # If a filter exists on the view that implements `get_ordering`
811 # then we defer to that filter to determine the ordering.
812 filter_cls = ordering_filters[0]
813 filter_instance = filter_cls()
814 ordering = filter_instance.get_ordering(request, queryset, view)
815 assert ordering is not None, (
816 'Using cursor pagination, but filter class {filter_cls} '
817 'returned a `None` ordering.'.format(
818 filter_cls=filter_cls.__name__
819 )
820 )
821 else:
822 # The default case is to check for an `ordering` attribute
823 # on this pagination instance.
824 ordering = self.ordering
825 assert ordering is not None, (
826 'Using cursor pagination, but no ordering attribute was declared '
827 'on the pagination class.'
828 )
829 assert '__' not in ordering, (
830 'Cursor pagination does not support double underscore lookups '
831 'for orderings. Orderings should be an unchanging, unique or '
832 'nearly-unique field on the model, such as "-created" or "pk".'
833 )
835 assert isinstance(ordering, (str, list, tuple)), (
836 'Invalid ordering. Expected string or tuple, but got {type}'.format(
837 type=type(ordering).__name__
838 )
839 )
841 if isinstance(ordering, str):
842 return (ordering,)
843 return tuple(ordering)
845 def decode_cursor(self, request):
846 """
847 Given a request with a cursor, return a `Cursor` instance.
848 """
849 # Determine if we have a cursor, and if so then decode it.
850 encoded = request.query_params.get(self.cursor_query_param)
851 if encoded is None:
852 return None
854 try:
855 querystring = b64decode(encoded.encode('ascii')).decode('ascii')
856 tokens = parse.parse_qs(querystring, keep_blank_values=True)
858 offset = tokens.get('o', ['0'])[0]
859 offset = _positive_int(offset, cutoff=self.offset_cutoff)
861 reverse = tokens.get('r', ['0'])[0]
862 reverse = bool(int(reverse))
864 position = tokens.get('p', [None])[0]
865 except (TypeError, ValueError):
866 raise NotFound(self.invalid_cursor_message)
868 return Cursor(offset=offset, reverse=reverse, position=position)
870 def encode_cursor(self, cursor):
871 """
872 Given a Cursor instance, return an url with encoded cursor.
873 """
874 tokens = {}
875 if cursor.offset != 0:
876 tokens['o'] = str(cursor.offset)
877 if cursor.reverse:
878 tokens['r'] = '1'
879 if cursor.position is not None:
880 tokens['p'] = cursor.position
882 querystring = parse.urlencode(tokens, doseq=True)
883 encoded = b64encode(querystring.encode('ascii')).decode('ascii')
884 return replace_query_param(self.base_url, self.cursor_query_param, encoded)
886 def _get_position_from_instance(self, instance, ordering):
887 field_name = ordering[0].lstrip('-')
888 if isinstance(instance, dict):
889 attr = instance[field_name]
890 else:
891 attr = getattr(instance, field_name)
892 return str(attr)
894 def get_paginated_response(self, data):
895 return Response(OrderedDict([
896 ('next', self.get_next_link()),
897 ('previous', self.get_previous_link()),
898 ('results', data)
899 ]))
901 def get_paginated_response_schema(self, schema):
902 return {
903 'type': 'object',
904 'properties': {
905 'next': {
906 'type': 'string',
907 'nullable': True,
908 },
909 'previous': {
910 'type': 'string',
911 'nullable': True,
912 },
913 'results': schema,
914 },
915 }
917 def get_html_context(self):
918 return {
919 'previous_url': self.get_previous_link(),
920 'next_url': self.get_next_link()
921 }
923 def to_html(self):
924 template = loader.get_template(self.template)
925 context = self.get_html_context()
926 return template.render(context)
928 def get_schema_fields(self, view):
929 assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
930 assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
931 fields = [
932 coreapi.Field(
933 name=self.cursor_query_param,
934 required=False,
935 location='query',
936 schema=coreschema.String(
937 title='Cursor',
938 description=force_str(self.cursor_query_description)
939 )
940 )
941 ]
942 if self.page_size_query_param is not None:
943 fields.append(
944 coreapi.Field(
945 name=self.page_size_query_param,
946 required=False,
947 location='query',
948 schema=coreschema.Integer(
949 title='Page size',
950 description=force_str(self.page_size_query_description)
951 )
952 )
953 )
954 return fields
956 def get_schema_operation_parameters(self, view):
957 parameters = [
958 {
959 'name': self.cursor_query_param,
960 'required': False,
961 'in': 'query',
962 'description': force_str(self.cursor_query_description),
963 'schema': {
964 'type': 'string',
965 },
966 }
967 ]
968 if self.page_size_query_param is not None:
969 parameters.append(
970 {
971 'name': self.page_size_query_param,
972 'required': False,
973 'in': 'query',
974 'description': force_str(self.page_size_query_description),
975 'schema': {
976 'type': 'integer',
977 },
978 }
979 )
980 return parameters