"""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 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 ' '
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()