Source code for chrysalio.lib.paging

"""Class to divide large lists of items into pages."""

from __future__ import annotations
from re import sub as re_sub, Match

from sqlalchemy.orm.query import Query

from pyramid.request import Request

from ..helpers import tags
from ..helpers.literal import Literal
from .i18n import _

DISPLAYS = ('cards', 'list')
DISPLAY_LABELS = {'cards': _('Display as cards'), 'list': _('Display as list')}
DISPLAYED_LABELS = {
    'cards': _('Displayed as cards'),
    'list': _('Displayed as list')
}
PAGE_DEFAULT_SIZE = 80
PAGE_SIZES = (('', ' '), 10, 20, 40, 80, 160, 320, 640, 1200, ('0', '∞'))


# =============================================================================
[docs] def sortable_column( request: Request, label: str, sort: str, current_sorting: str | None = None) -> str: """Output a header of column with `sort up` and `sort down` buttons. :type request: pyramid.request.Request :param request: Current request. :param str label: Label of column. :param str sort: Sort criteria. :param str current_sorting: (optional) Default current sorting. :rtype: helpers.literal.Literal """ current = request.params.get('sort') or current_sorting query_string = {} if request.GET: query_string.update(request.GET) html = '<a title="{0}"'.format( request.localizer.translate(_('Sort by ${l}', {'l': label.lower()}))) if current and sort == current[1:]: html += current[0] == '+' and \ ' class="cioSortAsc"' or ' class="cioSortDesc"' if current and sort == current[1:] and current[0] == '+': query_string['sort'] = f'-{sort}' else: query_string['sort'] = f'+{sort}' html += ' href="{0}"'.format( request.current_route_path(_query=query_string)) html += f'>{label}</a>' return Literal(html)
# =============================================================================
[docs] class Paging(list): """Divide large lists of items into pages. :type request: pyramid.request.Request :param request: Current request. :param str paging_id: Paging ID. :type collection: list-like object :param collection: Collection object being paged through. :param dict params: (optional) A dictionary with the following keys: ``page_size``, ``page``, ``sort`` and ``display``. :param int item_count: (optional) Number of items in the collection. :param tuple page_sizes: (optional) List of suggested page sizes. This class uses the following parameters in request: ``page_size``, ``page``, ``sort`` and ``display``. It stores its information and filters definitions in ``session['paging']``. This structure looks like: ``session['paging'] = (page_default_size, {'paging_id1': {'page_size': 80, 'page': 3, 'sort': 'name', 'display': 'cards'}, 'paging_id2': {...}, ...})`` """ # pylint: disable = too-many-instance-attributes # ------------------------------------------------------------------------- def __init__( self, request: Request, paging_id: str, collection, params: dict | None = None, item_count: int | None = None): """Constructor method.""" # Update paging session if params is None: params = self.params(request, paging_id) # Initialize variables self._request = request self.paging_id = paging_id if isinstance(collection, Query): full_list = _SQLAlchemyQuery(collection) else: full_list = collection self.item_count = item_count if item_count is not None else \ len(full_list) self.page_count = ((self.item_count - 1) // (params['page_size'])) + 1\ if params['page_size'] else 1 if params['page'] > self.page_count: params['page'] = self.page_count self.page = max(1, params['page']) self.items = [] self.page_size = params['page_size'] self.display = params['display'] # Compute the item list if self.item_count > 0: if self.page_size: first_item = (self.page - 1) * self.page_size + 1 last_item = min( first_item + self.page_size - 1, self.item_count) else: first_item = 1 last_item = self.item_count try: self.items = full_list[first_item - 1:last_item] except TypeError: raise TypeError("You can't use type %s!" % type(full_list)) list.__init__(self, self.items) # -------------------------------------------------------------------------
[docs] @classmethod def params( cls, request: Request | None, paging_id: str, default_sorting: str | None = None, default_display: str = 'cards') -> dict: """Return current paging parameters: page number, page size, sorting and display mode. :type request: pyramid.request.Request :param request: Current request. :param str paging_id: Paging ID. :param str default_sorting: (optional) Default sorting. :param str default_display: ('cards' or 'list', default='cards') Default display. :rtype: dict :return: The paging dictionary. See :class:`~.paging.Paging` class. """ if request is None: return { 'page': 1, 'page_size': PAGE_DEFAULT_SIZE, 'sort': default_sorting, 'display': default_display } if 'paging' not in request.session: request.session['paging'] = \ request.registry['settings']['page-size'] \ if 'settings' in request.registry else PAGE_DEFAULT_SIZE, {} if paging_id not in request.session['paging'][1]: request.session['paging'][1][paging_id] = { 'page': 1, 'page_size': request.session['paging'][0], 'sort': default_sorting, 'display': default_display } params = request.session['paging'][1][paging_id] if 'page' in request.params and request.params['page'].isdigit(): params['page'] = max(1, int(request.params['page'])) if 'page_size' in request.params \ and request.params['page_size'].strip(): params['page_size'] = int(request.params['page_size']) if request.params.get('sort'): params['sort'] = request.params['sort'] if params['sort'] is None: params['sort'] = default_sorting if request.params.get('display'): params['display'] = request.params['display'] \ if request.params['display'] in DISPLAYS else DISPLAYS[0] if request.POST: request.session['paging'][1][paging_id] = dict(params) params = request.session['paging'][1][paging_id] return params
# -------------------------------------------------------------------------
[docs] @classmethod def get_sort(cls, request: Request, paging_id: str) -> str | None: """Retrieve the sort criteria from the session. :type request: pyramid.request.Request :param request: Current request. :param str paging_id: ID of the paging. :rtype: :class:`str` or ``None`` """ if 'paging' not in request.session or \ paging_id not in request.session['paging'][1]: return None return request.session['paging'][1][paging_id]['sort']
# -------------------------------------------------------------------------
[docs] @classmethod def get_page(cls, request: Request, paging_id: str) -> int: """Retrieve the current page number from the session. :type request: pyramid.request.Request :param request: Current request. :param str paging_id: ID of the paging. :rtype: int """ if 'paging' not in request.session or \ paging_id not in request.session['paging'][1]: return 1 return request.session['paging'][1][paging_id]['page']
# -------------------------------------------------------------------------
[docs] def get_item(self, field_id: str, value): """Retrieve the first item whose field ``field_id`` has the value ``value``. :param str field_id: Name of the item field to search. :param value: Value to use to find the item. :rtype: :class:`dict` or ``None`` """ try: return next((k for k in self if k[field_id] == value)) except (StopIteration, AttributeError, TypeError): return None
# -------------------------------------------------------------------------
[docs] def set_current_ids(self, field_id: str): """Save in ``session['paging'][1][self.paging_id]['current_ids']`` the IDs of the items of the page. :param str field_id: Name of the item field used to store the IDs. """ if self._request is None or \ 'paging' not in self._request.session or \ self.paging_id not in self._request.session['paging'][1]: return self._request.session['paging'][1][self.paging_id]['current_ids'] = [ getattr(k, field_id) for k in self ]
# -------------------------------------------------------------------------
[docs] def pager_top(self) -> str: """Output a string with links to first, previous, next and last pages. :rtye: str """ html = '' translate = self._request.localizer.translate qstring = self._request.GET.copy() first_item = min((self.page - 1) * self.page_size + 1, self.item_count) last_item = min(first_item + self.page_size - 1, self.item_count) \ if self.page_size else self.item_count # First & previous if self.page > 1: qstring['page'] = 1 html = f'{html}'\ '<a href="{0}" class="cioPageFirst" title="{1}"> </a> '.format( self._request.current_route_path(_query=qstring), translate(_('First page'))) qstring['page'] = self.page - 1 html = f'{html}'\ '<a href="{0}" class="cioPagePrevious" title="{1}"> </a>'\ .format( self._request.current_route_path(_query=qstring), translate(_('Previous page'))) else: html = f'{html}'\ '<span class="cioPageFirst" title="{0}"> </span> '\ '<span class="cioPagePrevious" title="{1}"> </span>'.format( translate(_('First page')), translate(_('Previous page'))) # Items html = f'{html} {first_item}{last_item} / {self.item_count} ' # Next & last if self.page < self.page_count: qstring['page'] = self.page + 1 html = f'{html}'\ '<a href="{0}" class="cioPageNext" title="{1}"> </a> '.format( self._request.current_route_path(_query=qstring), translate(_('Next page'))) qstring['page'] = self.page_count html = f'{html}'\ '<a href="{0}" class="cioPageLast" title="{1}"> </a>'.format( self._request.current_route_path(_query=qstring), translate(_('Last page'))) else: html = f'{html}'\ '<span class="cioPageNext" title="{0}"> </span> '\ '<span class="cioPageLast" title="{1}"> </span>'.format( translate(_('Next page')), translate(_('Last page'))) return Literal(f'<span class="cioPagingPages">{html}</span>')
# -------------------------------------------------------------------------
[docs] def pager_bottom(self, pager_format: str = '~4~') -> str: """Output a string with links to some previous and next pages. :param str pager_format: (default='~4~') Format string that defines how the pager is rendered. :rtype: str """ # Replace ~...~ in token format by range of pages return Literal(re_sub(r'~(\d+)~', self._range, pager_format))
# -------------------------------------------------------------------------
[docs] def display_modes(self) -> str: """Output buttons to switch between cards and list mode. :rtype: str """ html = '' translate = self._request.localizer.translate qstring = self._request.GET.copy() for mode in DISPLAYS: if self.display == mode: html = f'{html}'\ '<span class="cioPagingDisplay{0}"'\ ' title="{1}"> </span> '.format( mode.capitalize(), translate(DISPLAYED_LABELS[mode])) else: qstring['display'] = mode html = f'{html}'\ ' <a href="{0}" class="cioPagingDisplay{1}"'\ ' title="{2}"> </a> '.format( self._request.current_route_path(_query=qstring), mode.capitalize(), translate(DISPLAY_LABELS[mode])) return Literal(f'<span class="cioPagingDisplay">{html}</span>')
# -------------------------------------------------------------------------
[docs] @classmethod def navigator( cls, request: Request, paging_id: str, item_id: str, url: str) -> str: """Return a piece of HTML to go to the previous and the next item. :type request: pyramid.request.Request :param request: Current request. :param str paging_id: Paging ID. :param str item_id: ID of the current item. :param str url: Pattern for the URL of previous and next button. :rtype: str """ if request is None or 'paging' not in request.session or \ paging_id not in request.session['paging'][1] or \ 'current_ids' not in request.session['paging'][1][paging_id]: return '' current_ids = request.session['paging'][1][paging_id]['current_ids'] if item_id not in current_ids: return '' # Previous item translate = request.localizer.translate index = current_ids.index(item_id) if index: html = '<a href="{0}" class="cioItemPrevious" title="{1}"> </a>'\ .format(url.replace('_ID_', str(current_ids[index - 1])), translate(_('Previous item'))) else: html = '<span class="cioItemPrevious" title="{0}"> </span>'\ .format(translate(_('No previous item'))) html = f'{html} {index + 1} / {len(current_ids)} ' # Next item if index < len(current_ids) - 1: html = '{0}<a href="{1}" class="cioItemNext" title="{2}"> </a>'\ .format(html, url.replace('_ID_', str(current_ids[index + 1])), translate(_('Next item'))) else: html = '{0}<span class="cioItemNext" title="{1}"> </span>'\ .format(html, translate(_('No next item'))) return Literal(f'<span class="cioPagingNavigator">{html}</span>')
# -------------------------------------------------------------------------
[docs] def sortable_column(self, label: str, sort: str) -> str: """Output a header of column with `sort up` and `sort down` buttons. See :func:`sortable_column`. :param str label: Label of column. :param str sort: Sort criteria. :rtype: helpers.literal.Literal """ if self._request is None: return '&nbsp;' return sortable_column( self._request, label, sort, self._request.session['paging'][1][self.paging_id]['sort'])
# ------------------------------------------------------------------------- def _range(self, regex_match: Match) -> str: """Return range of linked pages (e.g. '1 2 [3] 4 5 6 7 8'). :type regex_match: re.Match :param regex_match: A regular expressions match object containing the radius of linked pages around the current page in regex_match.group(1) as a string. :rtype: str """ query_string = self._request.GET.copy() radius = int(regex_match.group(1)) leftmost_page = max(1, self.page - radius) rightmost_page = min(self.page + radius, self.page_count) items = [] if self.page != 1 and leftmost_page > 1: items.append(self._link(query_string, '1', 1)) if leftmost_page > 2: items.append('…') for page in range(leftmost_page, rightmost_page + 1): if page == self.page: items.append('<span>%d</span>' % page) else: items.append(self._link(query_string, str(page), page)) if rightmost_page < self.page_count - 1: items.append('…') if self.page != self.page_count and rightmost_page < self.page_count: items.append( self._link( query_string, str(self.page_count), self.page_count)) return ' '.join(items) # ------------------------------------------------------------------------- def _link( self, query_string: dict, label: str, page_number: int | None = None) -> str: """Create an A-HREF tag. :param dict query_string: The current query string in a dictionary. :param str label: Text to be printed in the A-HREF tag. :param int page_number: (optional) Number of the page that the link points to. """ if page_number: query_string.update({'page': page_number}) return tags.link_to( label, self._request.current_route_path(_query=query_string))
# ============================================================================= class _SQLAlchemyQuery(): """Iterable that allows to get slices from an SQLAlchemy Query object.""" # pylint: disable = too-few-public-methods # ------------------------------------------------------------------------- def __init__(self, query): """Contructor method.""" self.query = query # ------------------------------------------------------------------------- def __getitem__(self, records): """Implement evaluation of self[key].""" if not isinstance(records, slice): # pragma: nocover raise TypeError('__getitem__ without slicing not supported') return self.query[records] # ------------------------------------------------------------------------- def __len__(self) -> int: """Implement the built-in function len().""" return self.query.count()