diff --git a/CHANGELOG.md b/CHANGELOG.md index 76568fe..dad38d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.10.0 (2024-08-18) - -### Feat - -- add multi-column sorting (frontend or backend) for grids - -### Fix - -- improve grid display when data is empty - ## v0.9.0 (2024-08-16) ### Feat diff --git a/pyproject.toml b/pyproject.toml index bfaf855..9e092c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.10.0" +version = "0.9.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -32,7 +32,6 @@ 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 0c53e07..a28f02c 100644 --- a/src/wuttaweb/grids/__init__.py +++ b/src/wuttaweb/grids/__init__.py @@ -26,8 +26,6 @@ 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, SortInfo +from .base import Grid, GridAction diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index e193109..328637d 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -27,13 +27,10 @@ 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 @@ -44,13 +41,6 @@ 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. @@ -126,117 +116,6 @@ 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_multiple` and :attr:`sort_on_backend`. - - .. attribute:: sort_multiple - - Boolean indicating whether "multi-column" sorting is allowed. - Default is ``True``; if this is ``False`` then only one column - may be sorted at a time. - - Only relevant if :attr:`sortable` is true, but applies to both - frontend and backend sorting. - - .. warning:: - - This feature is limited by frontend JS capabilities, - regardless of :attr:`sort_on_backend` value (i.e. for both - frontend and backend sorting). - - In particular, if the app theme templates use Vue 2 + Buefy, - then multi-column sorting should work. - - But not so with Vue 3 + Oruga, *yet* - see also the `open - issue `_ - regarding that. For now this flag is simply ignored for - Vue 3 + Oruga templates. - - Additionally, even with Vue 2 + Buefy this flag can only - allow the user to *request* a multi-column sort. Whereas - the "default sort" in the Vue component can only ever be - single-column, regardless of :attr:`sort_defaults`. - - .. 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()`, :attr:`sort_defaults` and - :attr:`active_sorters`. - - .. 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. (More - are allowed if :attr:`sort_multiple` is true, but see note - below.) Each list element is a :class:`SortInfo` tuple and - must correspond to an entry in :attr:`sorters`. - - Used with both frontend and backend sorting. - - See also :meth:`set_sort_defaults()` and - :attr:`active_sorters`. - - .. warning:: - - While the grid logic is built to handle multi-column - sorting, this feature is limited by frontend JS - capabilities. - - Even if ``sort_defaults`` contains multiple entries - (i.e. for multi-column sorting to be used "by default" for - the grid), only the *first* entry (i.e. single-column - sorting) will actually be used as the default for the Vue - component. - - See also :attr:`sort_multiple` for more details. - - .. attribute:: active_sorters - - List of sorters currently in effect for the grid; used by - :meth:`sort_data()`. - - Whereas :attr:`sorters` defines all "available" sorters, and - :attr:`sort_defaults` defines the "default" sorters, - ``active_sorters`` defines the "current/effective" sorters. - - This attribute is set by :meth:`load_settings()`; until that is - called it will not exist. - - This is conceptually a "subset" of :attr:`sorters` although a - different format is used here:: - - grid.active_sorters = [ - {'key': 'name', 'dir': 'asc'}, - {'key': 'id', 'dir': 'asc'}, - ] - - The above is for example only; there is usually no reason to - set this attribute directly. - - This list may contain multiple elements only if - :attr:`sort_multiple` is true. Otherewise it should always - have either zero or one element. - .. attribute:: paginated Boolean indicating whether the grid data should be paginated @@ -296,11 +175,6 @@ class Grid: renderers={}, actions=[], linked_columns=[], - sortable=False, - sort_multiple=True, - sort_on_backend=True, - sorters=None, - sort_defaults=None, paginated=False, paginate_on_backend=True, pagesize_options=None, @@ -322,22 +196,6 @@ class Grid: self.set_columns(columns or self.get_columns()) - # sorting - self.sortable = sortable - self.sort_multiple = sort_multiple - if self.sort_multiple and self.request.use_oruga: - log.warning("grid.sort_multiple is not implemented for Oruga-based templates") - self.sort_multiple = False - 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 = {} - self.set_sort_defaults(sort_defaults or []) - - # paging self.paginated = paginated self.paginate_on_backend = paginate_on_backend self.pagesize_options = pagesize_options or self.get_pagesize_options() @@ -544,302 +402,6 @@ 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=True): - """ - 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. Defaults to - ``True`` since this behavior is presumably expected, but - may be disabled if needed. - - 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, *args): - """ - Set the default sorting method for the grid. This sorting is - used unless/until the user requests a different sorting - method. - - ``args`` for this method are interpreted as follows: - - If 2 args are received, they should be for ``sortkey`` and - ``sortdir``; for instance:: - - grid.set_sort_defaults('name', 'asc') - - If just one 2-tuple arg is received, it is handled similarly:: - - grid.set_sort_defaults(('name', 'asc')) - - If just one string arg is received, the default ``sortdir`` is - assumed:: - - grid.set_sort_defaults('name') # assumes 'asc' - - Otherwise there should be just one list arg, elements of - which are each 2-tuples of ``(sortkey, sortdir)`` info:: - - grid.set_sort_defaults([('name', 'asc'), - ('value', 'desc')]) - - .. note:: - - Note that :attr:`sort_multiple` determines whether the grid - is actually allowed to have multiple sort defaults. The - defaults requested by the method call may be pruned if - necessary to accommodate that. - - Default sorting info is tracked via :attr:`sort_defaults`. - """ - - # convert args to sort defaults - sort_defaults = [] - if len(args) == 1: - if isinstance(args[0], str): - sort_defaults = [SortInfo(args[0], 'asc')] - elif isinstance(args[0], tuple) and len(args[0]) == 2: - sort_defaults = [SortInfo(*args[0])] - elif isinstance(args[0], list): - sort_defaults = [SortInfo(*tup) for tup in args[0]] - else: - raise ValueError("for just one positional arg, must pass string, 2-tuple or list") - elif len(args) == 2: - sort_defaults = [SortInfo(*args)] - else: - raise ValueError("must pass just one or two positional args") - - # prune if multi-column requested but not supported - if len(sort_defaults) > 1 and not self.sort_multiple: - log.warning("multi-column sorting is not enabled for the instance; " - "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 - - 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 ############################## @@ -898,19 +460,21 @@ class Grid: # configuration methods ############################## - def load_settings(self, persist=True): + def load_settings(self, store=True): """ - Load all effective settings for the grid. + Load all effective settings for the grid, from the following + places: - If the request GET params (query string) contains grid - settings, they are used; otherwise the settings are loaded - from user session. + * request params + * user session + + The first value found for a given setting will be applied to + the grid. .. note:: - As of now, "sorting" and "pagination" settings are the only - type supported by this logic. Settings for "filtering" - coming soon... + As of now, "pagination" settings are the only type + supported by this logic. Filter/sort coming soon... The overall logic for this method is as follows: @@ -919,149 +483,45 @@ 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, 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. + "remember" its current settings when user refreshes the page. - :param persist: Whether the collected settings should be saved - to the user session. + :param store: Flag indicating whether the collected settings + should then be saved to the user session. """ # initial default settings settings = {} - if self.sortable: - if self.sort_defaults: - # nb. as of writing neither Buefy nor Oruga support a - # multi-column *default* sort; so just use first sorter - sortinfo = self.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 - # 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') + # grab settings from request and/or user session + if self.paginated and self.paginate_on_backend: self.update_page_settings(settings) else: - # nothing found in request, so nothing new to save - persist = False + # no settings were found in request or user session, so + # nothing needs to be saved + 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) - # maybe save settings in user session, for next time - if persist: - self.persist_settings(settings, dest='session') - - # update ourself to reflect settings dict.. - - # sorting - if self.sortable: - # nb. doing this for frontend sorting also - 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'], - }) - # TODO: i thought this was needed, but now idk? - # # nb. when showing full index page (i.e. not partial) - # # this implies we must set the default sorter for Vue - # # component, and only single-column is allowed there. - # if not self.request.GET.get('partial'): - # break - - # paging + # update ourself to reflect settings if self.paginated and self.paginate_on_backend: self.pagesize = settings['pagesize'] self.page = settings['page'] - def request_has_settings(self, typ): + def request_has_settings(self): """ """ - - if typ == 'sort': - if 'sort1key' in self.request.GET: + for key in ['pagesize', 'page']: + if key 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 @@ -1084,42 +544,17 @@ class Grid: if page is not None: settings['page'] = int(page) - def persist_settings(self, settings, dest=None): + def persist_settings(self, settings): """ """ - if dest not in ('session',): - raise ValueError(f"invalid dest identifier: {dest}") + model = self.app.model + session = Session() # 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') @@ -1144,50 +579,12 @@ 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 data and return the result. This is called by - :meth:`get_visible_data()`. - - :param sorters: Optional list of sorters to use. If not - specified, the grid's :attr:`active_sorters` are used. - """ - if sorters is None: - sorters = self.active_sorters - if not sorters: - return data - - # nb. when data is a query, we want to apply sorters in the - # requested order, so the final query has order_by() in the - # correct "as-is" sequence. however when data is a list we - # must do the opposite, applying in the reverse order, so the - # final list has the most "important" sort(s) applied last. - if not isinstance(data, orm.Query): - sorters = reversed(sorters) - - for sorter in sorters: - 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 - data = sortfunc(data, sortdir) - - return data - def paginate_data(self, data): """ Apply pagination to the given data set, based on grid settings. @@ -1197,27 +594,9 @@ class Grid: This method is called by :meth:`get_visible_data()`. """ - 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 - - # and re-make the pager just to be safe (?) - pager = self.paginate_data(data) - + pager = paginate.Page(data, + items_per_page=self.pagesize, + page=self.page) return pager ############################## @@ -1295,33 +674,9 @@ class Grid: columns.append({ 'field': name, 'label': self.get_label(name), - 'sortable': self.is_sortable(name), }) return columns - def get_vue_active_sorters(self): - """ - Returns a list of Vue-compatible column sorter definitions. - - The list returned is the same as :attr:`active_sorters`; - however the format used in Vue is different. So this method - just "converts" them to the required format, e.g.:: - - # active_sorters format - {'key': 'name', 'dir': 'asc'} - - # get_vue_active_sorters() format - {'field': 'name', 'order': 'asc'} - - :returns: The :attr:`active_sorters` list, converted as - described above. - """ - sorters = [] - for sorter in self.active_sorters: - sorters.append({'field': sorter['key'], - 'order': sorter['dir']}) - return sorters - def get_vue_data(self): """ Returns a list of Vue-compatible data records. diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 568cfc7..e588450 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -3,37 +3,11 @@