diff --git a/restkiss/paginator.py b/restkiss/paginator.py new file mode 100644 index 0000000..6636c98 --- /dev/null +++ b/restkiss/paginator.py @@ -0,0 +1,168 @@ +import collections +import warnings +from math import ceil + + +class UnorderedObjectListWarning(RuntimeWarning): + pass + + +class InvalidPage(Exception): + pass + + +class PageNotAnInteger(InvalidPage): + pass + + +class EmptyPage(InvalidPage): + pass + + +class Paginator(object): + + def __init__(self, object_list, per_page, orphans=0, + allow_empty_first_page=True): + self.object_list = object_list + self._check_object_list_is_ordered() + self.per_page = int(per_page) + self.orphans = int(orphans) + self.allow_empty_first_page = allow_empty_first_page + + def validate_number(self, number): + """ + Validates the given 1-based page number. + """ + try: + number = int(number) + except (TypeError, ValueError): + raise PageNotAnInteger('That page number is not an integer') + if number < 1: + raise EmptyPage('That page number is less than 1') + if number > self.num_pages: + if number == 1 and self.allow_empty_first_page: + pass + else: + raise EmptyPage('That page contains no results') + return number + + def page(self, number): + """ + Returns a Page object for the given 1-based page number. + """ + number = self.validate_number(number) + bottom = (number - 1) * self.per_page + top = bottom + self.per_page + if top + self.orphans >= self.count: + top = self.count + return self._get_page(self.object_list[bottom:top], number, self) + + def _get_page(self, *args, **kwargs): + """ + Returns an instance of a single page. + + This hook can be used by subclasses to use an alternative to the + standard :cls:`Page` object. + """ + return Page(*args, **kwargs) + + def count(self): + """ + Returns the total number of objects, across all pages. + """ + try: + return self.object_list.count() + except (AttributeError, TypeError): + # AttributeError if object_list has no count() method. + # TypeError if object_list.count() requires arguments + # (i.e. is of type list). + return len(self.object_list) + + def num_pages(self): + """ + Returns the total number of pages. + """ + if self.count == 0 and not self.allow_empty_first_page: + return 0 + hits = max(1, self.count - self.orphans) + return int(ceil(hits / float(self.per_page))) + + @property + def page_range(self): + """ + Returns a 1-based range of pages for iterating through within + a template for loop. + """ + return range(1, self.num_pages + 1) + + def _check_object_list_is_ordered(self): + """ + Warn if self.object_list is unordered (typically a QuerySet). + """ + if hasattr(self.object_list, 'ordered') and not self.object_list.ordered: + warnings.warn( + 'Pagination may yield inconsistent results with an unordered ' + 'object_list: {!r}'.format(self.object_list), + UnorderedObjectListWarning + ) + + +QuerySetPaginator = Paginator # For backwards-compatibility. + + +class Page(collections.Sequence): + + def __init__(self, object_list, number, paginator): + self.object_list = object_list + self.number = number + self.paginator = paginator + + def __repr__(self): + return '' % (self.number, self.paginator.num_pages) + + def __len__(self): + return len(self.object_list) + + def __getitem__(self, index): + if not isinstance(index, (slice, int)): + raise TypeError + # The object_list is converted to a list so that if it was a QuerySet + # it won't be a database hit per __getitem__. + if not isinstance(self.object_list, list): + self.object_list = list(self.object_list) + return self.object_list[index] + + def has_next(self): + return self.number < self.paginator.num_pages + + def has_previous(self): + return self.number > 1 + + def has_other_pages(self): + return self.has_previous() or self.has_next() + + def next_page_number(self): + return self.paginator.validate_number(self.number + 1) + + def previous_page_number(self): + return self.paginator.validate_number(self.number - 1) + + def start_index(self): + """ + Returns the 1-based index of the first object on this page, + relative to total objects in the paginator. + """ + # Special case, return zero if no items. + if self.paginator.count == 0: + return 0 + return (self.paginator.per_page * (self.number - 1)) + 1 + + def end_index(self): + """ + Returns the 1-based index of the last object on this page, + relative to total objects found (hits). + """ + # Special case for the last page because there can be orphans. + if self.number == self.paginator.num_pages: + return self.paginator.count + return self.number * self.paginator.per_page diff --git a/restkiss/resources.py b/restkiss/resources.py index f6ad6c9..0fac27f 100644 --- a/restkiss/resources.py +++ b/restkiss/resources.py @@ -1,9 +1,9 @@ -import six import sys from .constants import OK, CREATED, ACCEPTED, NO_CONTENT from .data import Data -from .exceptions import MethodNotImplemented, Unauthorized +from .exceptions import MethodNotImplemented, Unauthorized, BadRequest +from .paginator import Paginator from .preparers import Preparer from .serializers import JSONSerializer from .utils import format_traceback @@ -268,7 +268,7 @@ def handle(self, endpoint, *args, **kwargs): try: # Use ``.get()`` so we can also dodge potentially incorrect # ``endpoint`` errors as well. - if not method in self.http_methods.get(endpoint, {}): + if method not in self.http_methods.get(endpoint, {}): raise MethodNotImplemented( "Unsupported method '{0}' for {1} endpoint.".format( method, @@ -385,7 +385,23 @@ def serialize(self, method, endpoint, data): return self.serialize_list(data) return self.serialize_detail(data) + def paginate_data(self, data): + page_size = getattr(self, 'page_size', 20) + paginator = Paginator(data, page_size) + + try: + page_number = int(self.request.GET.get('p', 1)) + except ValueError: + page_number = None + + if page_number not in paginator.page_range: + raise BadRequest('Invalid page number') + + self.page = paginator.page(page_number) + return self.page.object_list + def serialize_list(self, data): + """ Given a collection of data (``objects`` or ``dicts``), serializes them. @@ -398,6 +414,9 @@ def serialize_list(self, data): if data is None: return '' + if getattr(self, 'paginate', False): + data = self.paginate_data(data) + # Check for a ``Data``-like object. We should assume ``True`` (all # data gets prepared) unless it's explicitly marked as not. if not getattr(data, 'should_prepare', True):