Merge pull request #3229 from minrk/configurable-pagination

make pagination configurable
This commit is contained in:
Min RK
2020-10-29 10:53:29 +01:00
committed by GitHub
4 changed files with 155 additions and 72 deletions

View File

@@ -77,6 +77,7 @@ from .user import UserDict
from .oauth.provider import make_provider from .oauth.provider import make_provider
from ._data import DATA_FILES_PATH from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request from .log import CoroutineLogFormatter, log_request
from .pagination import Pagination
from .proxy import Proxy, ConfigurableHTTPProxy from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import ( from .utils import (
@@ -279,7 +280,7 @@ class JupyterHub(Application):
@default('classes') @default('classes')
def _load_classes(self): def _load_classes(self):
classes = [Spawner, Authenticator, CryptKeeper] classes = [Spawner, Authenticator, CryptKeeper, Pagination]
for name, trait in self.traits(config=True).items(): for name, trait in self.traits(config=True).items():
# load entry point groups into configurable class list # load entry point groups into configurable class list
# so that they show up in config files, etc. # so that they show up in config files, etc.

View File

@@ -453,7 +453,7 @@ class AdminHandler(BaseHandler):
@web.authenticated @web.authenticated
@admin_only @admin_only
async def get(self): async def get(self):
page, per_page, offset = Pagination.get_page_args(self) page, per_page, offset = Pagination(config=self.config).get_page_args(self)
available = {'name', 'admin', 'running', 'last_activity'} available = {'name', 'admin', 'running', 'last_activity'}
default_sort = ['admin', 'name'] default_sort = ['admin', 'name']
@@ -511,7 +511,11 @@ class AdminHandler(BaseHandler):
total = self.db.query(orm.User.id).count() total = self.db.query(orm.User.id).count()
pagination = Pagination( pagination = Pagination(
url=self.request.uri, total=total, page=page, per_page=per_page, url=self.request.uri,
total=total,
page=page,
per_page=per_page,
config=self.config,
) )
auth_state = await self.current_user.get_auth_state() auth_state = await self.current_user.get_auth_state()

View File

@@ -1,69 +1,94 @@
"""Basic class to manage pagination utils.""" """Basic class to manage pagination utils."""
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from traitlets import Bool
from traitlets import default
from traitlets import Integer
from traitlets import observe
from traitlets import Unicode
from traitlets import validate
from traitlets.config import Configurable
class Pagination: class Pagination(Configurable):
_page_name = 'page' # configurable options
_per_page_name = 'per_page' default_per_page = Integer(
_default_page = 1 100,
_default_per_page = 100 config=True,
_max_per_page = 250 help="Default number of entries per page for paginated results.",
)
def __init__(self, *args, **kwargs): max_per_page = Integer(
"""Potential parameters. 250,
**url**: URL in request config=True,
**page**: current page in use help="Maximum number of entries per page for paginated results.",
**per_page**: number of records to display in the page. By default 100 )
**total**: total records considered while paginating
"""
self.page = kwargs.get(self._page_name, 1)
if self.per_page > self._max_per_page: # state variables
self.per_page = self._max_per_page url = Unicode("")
page = Integer(1)
per_page = Integer(1, min=1)
self.total = int(kwargs.get('total', 0)) @default("per_page")
self.url = kwargs.get('url') or self.get_url() def _default_per_page(self):
self.init_values() return self.default_per_page
def init_values(self): @validate("per_page")
self._cached = {} def _limit_per_page(self, proposal):
self.skip = (self.page - 1) * self.per_page if self.max_per_page and proposal.value > self.max_per_page:
pages = divmod(self.total, self.per_page) return self.max_per_page
self.total_pages = pages[0] + 1 if pages[1] else pages[0] if proposal.value <= 1:
return 1
return proposal.value
self.has_prev = self.page > 1 @observe("max_per_page")
self.has_next = self.page < self.total_pages def _apply_max(self, change):
if change.new:
self.per_page = min(change.new, self.per_page)
total = Integer(0)
total_pages = Integer(0)
@default("total_pages")
def _calculate_total_pages(self):
total_pages = self.total // self.per_page
if self.total % self.per_page:
# there's a remainder, add 1
total_pages += 1
return total_pages
@observe("per_page", "total")
def _update_total_pages(self, change):
"""Update total_pages when per_page or total is changed"""
self.total_pages = self._calculate_total_pages()
separator = Unicode("...")
@classmethod
def get_page_args(self, handler): def get_page_args(self, handler):
""" """
This method gets the arguments used in the webpage to configurate the pagination This method gets the arguments used in the webpage to configurate the pagination
In case of no arguments, it uses the default values from this class In case of no arguments, it uses the default values from this class
It returns: Returns:
- self.page: The page requested for paginating or the default value (1) - page: The page requested for paginating or the default value (1)
- self.per_page: The number of items to return in this page. By default 100 and no more than 250 - per_page: The number of items to return in this page. No more than max_per_page
- self.per_page * (self.page - 1): The offset to consider when managing pagination via the ORM - offset: The offset to consider when managing pagination via the ORM
""" """
self.page = handler.get_argument(self._page_name, self._default_page) page = handler.get_argument("page", 1)
self.per_page = handler.get_argument( per_page = handler.get_argument("per_page", self.default_per_page)
self._per_page_name, self._default_per_page
)
try: try:
self.per_page = int(self.per_page) self.per_page = int(per_page)
if self.per_page > self._max_per_page: except Exception:
self.per_page = self._max_per_page
except:
self.per_page = self._default_per_page self.per_page = self._default_per_page
try: try:
self.page = int(self.page) self.page = int(page)
if self.page < 1: if self.page < 1:
self.page = self._default_page self.page = 1
except: except:
self.page = self._default_page self.page = 1
return self.page, self.per_page, self.per_page * (self.page - 1) return self.page, self.per_page, self.per_page * (self.page - 1)
@@ -91,38 +116,44 @@ class Pagination:
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference. (in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
""" """
self.separator_character = '...' before_page = 2
default_pages_to_render = 7 after_page = 2
after_page = 5 window_size = before_page + after_page + 1
before_end = 2
# Add 1 to self.total_pages since our default page is 1 and not 0 # Add 1 to total_pages since our starting page is 1 and not 0
total_pages = self.total_pages + 1 last_page = self.total_pages
pages = [] pages = []
if total_pages > default_pages_to_render: # will default window + start, end fit without truncation?
if self.page > 1: if self.total_pages > window_size + 2:
pages.extend([1, '...']) if self.page - before_page > 1:
# before_page will not reach page 1
pages.append(1)
if self.page - before_page > 2:
# before_page will not reach page 2, need separator
pages.append(self.separator)
if total_pages < self.page + after_page: pages.extend(range(max(1, self.page - before_page), self.page))
pages.extend(list(range(self.page, total_pages))) # we now have up to but not including self.page
if self.page + after_page + 1 >= last_page:
# after_page gets us to the end
pages.extend(range(self.page, last_page + 1))
else: else:
if total_pages >= self.page + after_page + before_end: # add full after_page entries
pages.extend(list(range(self.page, self.page + after_page))) pages.extend(range(self.page, self.page + after_page + 1))
pages.append('...') # add separator *if* this doesn't get to last page - 1
pages.extend(list(range(total_pages - before_end, total_pages))) if self.page + after_page < last_page - 1:
else: pages.append(self.separator)
pages.extend(list(range(self.page, self.page + after_page))) pages.append(last_page)
if self.page + after_page < total_pages:
# show only last page when the after_page window left space to show it
pages.append('...')
pages.extend(list(range(total_pages - 1, total_pages)))
return pages return pages
else: else:
return list(range(1, total_pages)) # everything will fit, nothing to think about
# always return at least one page
return list(range(1, last_page + 1)) or [1]
@property @property
def links(self): def links(self):
@@ -155,9 +186,11 @@ class Pagination:
page=page page=page
) )
) )
elif page == self.separator_character: elif page == self.separator:
links.append( links.append(
'<li class="disabled"><span> <span aria-hidden="true">...</span></span></li>' '<li class="disabled"><span> <span aria-hidden="true">{separator}</span></span></li>'.format(
separator=self.separator
)
) )
else: else:
links.append( links.append(

View File

@@ -0,0 +1,45 @@
"""tests for pagination"""
from pytest import mark
from pytest import raises
from traitlets.config import Config
from jupyterhub.pagination import Pagination
def test_per_page_bounds():
cfg = Config()
cfg.Pagination.max_per_page = 10
p = Pagination(config=cfg, per_page=20, total=100)
assert p.per_page == 10
with raises(Exception):
p.per_page = 0
@mark.parametrize(
"page, per_page, total, expected",
[
(1, 10, 99, [1, 2, 3, "...", 10]),
(2, 10, 99, [1, 2, 3, 4, "...", 10]),
(3, 10, 99, [1, 2, 3, 4, 5, "...", 10]),
(4, 10, 99, [1, 2, 3, 4, 5, 6, "...", 10]),
(5, 10, 99, [1, "...", 3, 4, 5, 6, 7, "...", 10]),
(6, 10, 99, [1, "...", 4, 5, 6, 7, 8, "...", 10]),
(7, 10, 99, [1, "...", 5, 6, 7, 8, 9, 10]),
(8, 10, 99, [1, "...", 6, 7, 8, 9, 10]),
(9, 10, 99, [1, "...", 7, 8, 9, 10]),
(1, 20, 99, [1, 2, 3, 4, 5]),
(1, 10, 0, [1]),
(1, 10, 1, [1]),
(1, 10, 10, [1]),
(1, 10, 11, [1, 2]),
(1, 10, 50, [1, 2, 3, 4, 5]),
(1, 10, 60, [1, 2, 3, 4, 5, 6]),
(1, 10, 70, [1, 2, 3, 4, 5, 6, 7]),
(1, 10, 80, [1, 2, 3, "...", 8]),
],
)
def test_window(page, per_page, total, expected):
cfg = Config()
cfg.Pagination
pagination = Pagination(page=page, per_page=per_page, total=total)
assert pagination.calculate_pages_window() == expected