diff --git a/pyproject.toml b/pyproject.toml index 9e092c6..d6e70e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", "paginate", + "paginate_sqlalchemy", "pyramid>=2", "pyramid_beaker", "pyramid_deform", diff --git a/src/wuttaweb/grids/__init__.py b/src/wuttaweb/grids/__init__.py index a28f02c..0c53e07 100644 --- a/src/wuttaweb/grids/__init__.py +++ b/src/wuttaweb/grids/__init__.py @@ -26,6 +26,8 @@ Grids Library The ``wuttaweb.grids`` namespace contains the following: * :class:`~wuttaweb.grids.base.Grid` +* :class:`~wuttaweb.grids.base.GridAction` +* :class:`~wuttaweb.grids.base.SortInfo` """ -from .base import Grid, GridAction +from .base import Grid, GridAction, SortInfo diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index d8dec34..e6d3217 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -27,10 +27,13 @@ Base grid classes import functools import json import logging +from collections import namedtuple import sqlalchemy as sa +from sqlalchemy import orm import paginate +from paginate_sqlalchemy import SqlalchemyOrmPage from pyramid.renderers import render from webhelpers2.html import HTML @@ -41,6 +44,13 @@ from wuttaweb.util import FieldList, get_model_fields, make_json_safe log = logging.getLogger(__name__) +SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir']) +SortInfo.__doc__ = """ +Named tuple to track sorting info. + +Elements of :attr:`~Grid.sort_defaults` will be of this type. +""" + class Grid: """ Base class for all grids. @@ -116,6 +126,52 @@ class Grid: See also :meth:`set_link()` and :meth:`is_linked()`. + .. attribute:: sortable + + Boolean indicating whether *any* column sorting is allowed for + the grid. Default is ``False``. + + See also :attr:`sort_on_backend`. + + .. attribute:: sort_on_backend + + Boolean indicating whether the grid data should be sorted on the + backend. Default is ``True``. + + If ``False``, the client-side Vue component will handle the + sorting. + + Only relevant if :attr:`sortable` is also true. + + .. attribute:: sorters + + Dict of functions to use for backend sorting. + + Only relevant if both :attr:`sortable` and + :attr:`sort_on_backend` are true. + + See also :meth:`set_sorter()`. + + .. attribute:: sort_defaults + + List of options to be used for default sorting, until the user + requests a different sorting method. + + This list usually contains either zero or one elements. Each + element is a :class:`SortInfo` tuple. + + Used with both frontend and backend sorting. + + See also :meth:`set_sort_defaults()`. + + .. note:: + + While the grid logic is meant to handle multi-column + sorting, that is not yet fully implemented. + + Therefore only the *first* element from this list is used + for the actual default sorting, regardless. + .. attribute:: paginated Boolean indicating whether the grid data should be paginated @@ -175,6 +231,10 @@ class Grid: renderers={}, actions=[], linked_columns=[], + sortable=False, + sort_on_backend=True, + sorters=None, + sort_defaults=None, paginated=False, paginate_on_backend=True, pagesize_options=None, @@ -196,6 +256,35 @@ class Grid: self.set_columns(columns or self.get_columns()) + # sorting + self.sortable = sortable + self.sort_on_backend = sort_on_backend + if sorters is not None: + self.sorters = sorters + elif self.sortable and self.sort_on_backend: + self.sorters = self.make_backend_sorters() + else: + self.sorters = {} + if sort_defaults: + if isinstance(sort_defaults, str): + key = sort_defaults + sort_defaults = [SortInfo(key, 'asc')] + elif (isinstance(sort_defaults, tuple) + and len(sort_defaults) == 2 + and all([isinstance(el, str) for el in sort_defaults])): + sort_defaults = [SortInfo(*sort_defaults)] + else: + sort_defaults = [SortInfo(*info) for info in sort_defaults] + if len(sort_defaults) > 1: + log.warning("multiple sort defaults are not yet supported; " + "list will be pruned to first element for '%s' grid: %s", + self.key, sort_defaults) + sort_defaults = [sort_defaults[0]] + self.sort_defaults = sort_defaults + else: + self.sort_defaults = [] + + # paging self.paginated = paginated self.paginate_on_backend = paginate_on_backend self.pagesize_options = pagesize_options or self.get_pagesize_options() @@ -402,6 +491,252 @@ class Grid: return True return False + ############################## + # sorting methods + ############################## + + def make_backend_sorters(self, sorters=None): + """ + Make backend sorters for all columns in the grid. + + This is called by the constructor, if both :attr:`sortable` + and :attr:`sort_on_backend` are true. + + For each column in the grid, this checks the provided + ``sorters`` and if the column is not yet in there, will call + :meth:`make_sorter()` to add it. + + .. note:: + + This only works if grid has a :attr:`model_class`. If not, + this method just returns the initial sorters (or empty + dict). + + :param sorters: Optional dict of initial sorters. Any + existing sorters will be left intact, not replaced. + + :returns: Final dict of all sorters. Includes any from the + initial ``sorters`` param as well as any which were + created. + """ + sorters = sorters or {} + + if self.model_class: + for key in self.columns: + if key in sorters: + continue + prop = getattr(self.model_class, key, None) + if prop and isinstance(prop.property, orm.ColumnProperty): + sorters[prop.key] = self.make_sorter(prop) + + return sorters + + def make_sorter(self, columninfo, keyfunc=None, foldcase=False): + """ + Returns a function suitable for use as a backend sorter on the + given column. + + Code usually does not need to call this directly. See also + :meth:`set_sorter()`, which calls this method automatically. + + :param columninfo: Can be either a model property (see below), + or a column name. + + :param keyfunc: Optional function to use as the "sort key + getter" callable, if the sorter is manual (as opposed to + SQLAlchemy query). More on this below. If not specified, + a default function is used. + + :param foldcase: If the sorter is manual (not SQLAlchemy), and + the column data is of text type, this may be used to + automatically "fold case" for the sorting. + + The term "model property" is a bit technical, an example + should help to clarify:: + + model = self.app.model + grid = Grid(self.request, model_class=model.Person) + + # explicit property + sorter = grid.make_sorter(model.Person.full_name) + + # property name works if grid has model class + sorter = grid.make_sorter('full_name') + + # nb. this will *not* work + person = model.Person(full_name="John Doe") + sorter = grid.make_sorter(person.full_name) + + The ``keyfunc`` param allows you to override the way sort keys + are obtained from data records (this only applies for a + "manual" sort, where data is a list and not a SQLAlchemy + query):: + + data = [ + {'foo': 1}, + {'bar': 2}, + ] + + # nb. no model_class, just as an example + grid = Grid(self.request, columns=['foo', 'bar'], data=data) + + def getkey(obj): + if obj.get('foo') + return obj['foo'] + if obj.get('bar'): + return obj['bar'] + return '' + + # nb. sortfunc will ostensibly sort by 'foo' column, but in + # practice it is sorted per value from getkey() above + sortfunc = grid.make_sorter('foo', keyfunc=getkey) + sorted_data = sortfunc(data, 'asc') + + :returns: A function suitable for backend sorting. This + function will behave differently when it is given a + SQLAlchemy query vs. a "list" of data. In either case it + will return the sorted result. + + This function may be called as shown above. It expects 2 + args: ``(data, direction)`` + """ + model_class = None + model_property = None + if isinstance(columninfo, str): + key = columninfo + model_class = self.model_class + model_property = getattr(self.model_class, key, None) + else: + model_property = columninfo + model_class = model_property.class_ + key = model_property.key + + def sorter(data, direction): + + # query is sorted with order_by() + if isinstance(data, orm.Query): + if not model_property: + raise TypeError(f"grid sorter for '{key}' does not map to a model property") + query = data + return query.order_by(getattr(model_property, direction)()) + + # other data is sorted manually. first step is to + # identify the function used to produce a sort key for + # each record + kfunc = keyfunc + if not kfunc: + if model_property: + # TODO: may need this for String etc. as well? + if isinstance(model_property.type, sa.Text): + if foldcase: + kfunc = lambda obj: (obj[key] or '').lower() + else: + kfunc = lambda obj: obj[key] or '' + if not kfunc: + # nb. sorting with this can raise error if data + # contains varying types, e.g. str and None + kfunc = lambda obj: obj[key] + + # then sort the data and return + return sorted(data, key=kfunc, reverse=direction == 'desc') + + # TODO: this should be improved; is needed in tailbone for + # multi-column sorting with sqlalchemy queries + if model_property: + sorter._class = model_class + sorter._column = model_property + + return sorter + + def set_sorter(self, key, sortinfo=None): + """ + Set/override the backend sorter for a column. + + Only relevant if both :attr:`sortable` and + :attr:`sort_on_backend` are true. + + :param key: Name of column. + + :param sortinfo: Can be either a sorter callable, or else a + model property (see below). + + If ``sortinfo`` is a callable, it will be used as-is for the + backend sorter. + + Otherwise :meth:`make_sorter()` will be called to obtain the + backend sorter. The ``sortinfo`` will be passed along to that + call; if it is empty then ``key`` will be used instead. + + A backend sorter callable must accept ``(data, direction)`` + args and return the sorted data/query, for example:: + + model = self.app.model + grid = Grid(self.request, model_class=model.Person) + + def sort_full_name(query, direction): + sortspec = getattr(model.Person.full_name, direction) + return query.order_by(sortspec()) + + grid.set_sorter('full_name', sort_full_name) + + See also :meth:`remove_sorter()` and :meth:`is_sortable()`. + Backend sorters are tracked via :attr:`sorters`. + """ + sorter = None + + if sortinfo and callable(sortinfo): + sorter = sortinfo + else: + sorter = self.make_sorter(sortinfo or key) + + self.sorters[key] = sorter + + def remove_sorter(self, key): + """ + Remove the backend sorter for a column. + + See also :meth:`set_sorter()`. + """ + self.sorters.pop(key, None) + + def set_sort_defaults(self, sortkey, sortdir='asc'): + """ + Set the default sorting method for the grid. + + This sorting is used unless/until the user requests a + different sorting method. + + :param sortkey: Name of the column by which to sort. + + :param sortdir: Must be either ``'asc'`` or ``'desc'``. + + Default sorting info is tracked via :attr:`sort_defaults`. + """ + self.sort_defaults = [SortInfo(sortkey, sortdir)] + + def is_sortable(self, key): + """ + Returns boolean indicating if a given column should allow + sorting. + + If :attr:`sortable` is false, this always returns ``False``. + + For frontend sorting (i.e. :attr:`sort_on_backend` is false), + this always returns ``True``. + + For backend sorting, may return true or false depending on + whether the column is listed in :attr:`sorters`. + + :param key: Column key as string. + + See also :meth:`set_sorter()`. + """ + if not self.sortable: + return False + if self.sort_on_backend: + return key in self.sorters + return True + ############################## # paging methods ############################## @@ -462,19 +797,17 @@ class Grid: def load_settings(self, store=True): """ - Load all effective settings for the grid, from the following - places: + Load all effective settings for the grid. - * request params - * user session - - The first value found for a given setting will be applied to - the grid. + If the request GET params (query string) contains grid + settings, they are used; otherwise the settings are loaded + from user session. .. note:: - As of now, "pagination" settings are the only type - supported by this logic. Filter/sort coming soon... + As of now, "sorting" and "pagination" settings are the only + type supported by this logic. Settings for "filtering" + coming soon... The overall logic for this method is as follows: @@ -483,45 +816,143 @@ class Grid: * optionally save settings to user session Saving the settings to user session will allow the grid to - "remember" its current settings when user refreshes the page. + remember its current settings when user refreshes the page, or + navigates away then comes back. Therefore normally, settings + are saved each time they are loaded. Note that such settings + are wiped upon user logout. - :param store: Flag indicating whether the collected settings - should then be saved to the user session. + :param store: Whether the collected settings should be saved + to the user session. """ # initial default settings settings = {} + if self.sortable: + if self.sort_defaults: + sort_defaults = self.sort_defaults + if len(sort_defaults) > 1: + log.warning("multiple sort defaults are not yet supported; " + "list will be pruned to first element for '%s' grid: %s", + self.key, sort_defaults) + sort_defaults = [sort_defaults[0]] + sortinfo = sort_defaults[0] + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir + else: + settings['sorters.length'] = 0 if self.paginated and self.paginate_on_backend: settings['pagesize'] = self.pagesize settings['page'] = self.page - # grab settings from request and/or user session - if self.paginated and self.paginate_on_backend: + # update settings dict based on what we find in the request + # and/or user session. always prioritize the former. + + if self.request_has_settings('sort'): + self.update_sort_settings(settings, src='request') + self.update_page_settings(settings) + + elif self.request_has_settings('page'): + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) else: - # no settings were found in request or user session, so - # nothing needs to be saved + # nothing found in request, so nothing new to save store = False + # but still should load whatever is in user session + self.update_sort_settings(settings, src='session') + self.update_page_settings(settings) + # maybe store settings in user session, for next time if store: - self.persist_settings(settings) + self.persist_settings(settings, dest='session') # update ourself to reflect settings + if self.sortable: + # and self.sort_on_backend: + self.active_sorters = [] + for i in range(1, settings['sorters.length'] + 1): + self.active_sorters.append({ + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], + }) if self.paginated and self.paginate_on_backend: self.pagesize = settings['pagesize'] self.page = settings['page'] - def request_has_settings(self): + def request_has_settings(self, typ): """ """ - for key in ['pagesize', 'page']: - if key in self.request.GET: + + if typ == 'sort': + if 'sort1key' in self.request.GET: return True + + elif typ == 'page': + for key in ['pagesize', 'page']: + if key in self.request.GET: + return True + return False + def get_setting(self, settings, key, src='session', default=None, + normalize=lambda v: v): + """ """ + + if src == 'request': + value = self.request.GET.get(key) + if value is not None: + try: + return normalize(value) + except ValueError: + pass + + elif src == 'session': + value = self.request.session.get(f'grid.{self.key}.{key}') + if value is not None: + return normalize(value) + + # if src had nothing, try default/existing settings + value = settings.get(key) + if value is not None: + return normalize(value) + + # okay then, default it is + return default + + def update_sort_settings(self, settings, src=None): + """ """ + if not (self.sortable and self.sort_on_backend): + return + + if src == 'request': + i = 1 + while True: + skey = f'sort{i}key' + if skey in self.request.GET: + settings[f'sorters.{i}.key'] = self.get_setting(settings, skey, + src='request') + settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir', + src='request', + default='asc') + else: + break + i += 1 + settings['sorters.length'] = i - 1 + + elif src == 'session': + settings['sorters.length'] = self.get_setting(settings, 'sorters.length', + src='session', normalize=int) + for i in range(1, settings['sorters.length'] + 1): + for key in ('key', 'dir'): + skey = f'sorters.{i}.{key}' + settings[skey] = self.get_setting(settings, skey, src='session') + def update_page_settings(self, settings): """ """ + if not (self.paginated and self.paginate_on_backend): + return + # update the settings dict from request and/or user session # pagesize @@ -544,17 +975,42 @@ class Grid: if page is not None: settings['page'] = int(page) - def persist_settings(self, settings): + def persist_settings(self, settings, dest=None): """ """ - model = self.app.model - session = Session() + if dest not in ('session',): + raise ValueError(f"invalid dest identifier: {dest}") # func to save a setting value to user session def persist(key, value=lambda k: settings.get(k)): + assert dest == 'session' skey = f'grid.{self.key}.{key}' self.request.session[skey] = value(key) + # sort settings + if self.sortable: + + # first must clear all sort settings from dest. this is + # because number of sort settings will vary, so we delete + # all and then write all + + if dest == 'session': + # remove sort settings from user session + prefix = f'grid.{self.key}.sorters.' + for key in list(self.request.session): + if key.startswith(prefix): + del self.request.session[key] + + # now save sort settings to dest + if 'sorters.length' in settings: + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') + + # pagination settings if self.paginated and self.paginate_on_backend: + + # save to dest persist('pagesize') persist('page') @@ -579,12 +1035,42 @@ class Grid: """ data = self.data or [] + if self.sortable and self.sort_on_backend: + data = self.sort_data(data) + if self.paginated and self.paginate_on_backend: self.pager = self.paginate_data(data) data = self.pager return data + def sort_data(self, data, sorters=None): + """ + Sort the given query according to current settings, and return the result. + + Optional list of sorters to use. If not specified, the grid's + "active" sorter list is used. + """ + if sorters is None: + sorters = self.active_sorters + if not sorters: + return data + if len(sorters) != 1: + raise NotImplementedError("mulit-column sorting not yet supported") + + # our one and only active sorter + sorter = sorters[0] + sortkey = sorter['key'] + sortdir = sorter['dir'] + + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data + + # invoke the sorter + return sortfunc(data, sortdir) + def paginate_data(self, data): """ Apply pagination to the given data set, based on grid settings. @@ -594,17 +1080,27 @@ class Grid: This method is called by :meth:`get_visible_data()`. """ - # nb. empty data should never have a page number > 1 - if not data: - self.page = 1 - # nb. must also update user session if applicable + if isinstance(data, orm.Query): + pager = SqlalchemyOrmPage(data, + items_per_page=self.pagesize, + page=self.page) + + else: + pager = paginate.Page(data, + items_per_page=self.pagesize, + page=self.page) + + # pager may have detected that our current page is outside the + # valid range. if so we should update ourself to match + if pager.page != self.page: + self.page = pager.page key = f'grid.{self.key}.page' if key in self.request.session: self.request.session[key] = self.page - pager = paginate.Page(data, - items_per_page=self.pagesize, - page=self.page) + # and re-make the pager just to be safe (?) + pager = self.paginate_data(data) + return pager ############################## @@ -682,6 +1178,7 @@ class Grid: columns.append({ 'field': name, 'label': self.get_label(name), + 'sortable': self.is_sortable(name), }) return columns diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 5287124..98a7ce3 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -3,11 +3,20 @@