Add back-end support for multi-column grid sorting

or very nearly, anyway.  front-end still just supports 1 column yet
This commit is contained in:
Lance Edgar 2023-10-08 14:29:01 -05:00
parent 4beca7af20
commit 6d7754cf2a
9 changed files with 222 additions and 202 deletions

View file

@ -24,12 +24,13 @@
Core Grid Classes
"""
from urllib.parse import urlencode
import warnings
import logging
from six.moves import urllib
import sqlalchemy as sa
from sqlalchemy import orm
from sa_filters import apply_sort
from rattail.db.types import GPCType
from rattail.util import prettify, pretty_boolean, pretty_quantity
@ -552,48 +553,6 @@ class Grid(object):
return self.url(obj)
return self.url
def make_webhelpers_grid(self):
kwargs = dict(self._whgrid_kwargs)
kwargs['request'] = self.request
kwargs['url'] = self.make_url
columns = list(self.columns)
column_labels = kwargs.setdefault('column_labels', {})
column_formats = kwargs.setdefault('column_formats', {})
for key, value in self.labels.items():
column_labels.setdefault(key, value)
if self.checkboxes:
columns.insert(0, 'checkbox')
column_labels['checkbox'] = tags.checkbox('check-all')
column_formats['checkbox'] = self.checkbox_column_format
if self.renderers:
kwargs['renderers'] = self.renderers
if self.extra_row_class:
kwargs['extra_record_class'] = self.extra_row_class
if self.linked_columns:
kwargs['linked_columns'] = list(self.linked_columns)
if self.main_actions or self.more_actions:
columns.append('actions')
column_formats['actions'] = self.actions_column_format
# TODO: pretty sure this factory doesn't serve all use cases yet?
factory = CustomWebhelpersGrid
# factory = webhelpers2_grid.Grid
if self.sortable:
# factory = CustomWebhelpersGrid
kwargs['order_column'] = self.sortkey
kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
grid = factory(self.make_visible_data(), columns, **kwargs)
if self.sortable:
grid.exclude_ordering = list([key for key in grid.exclude_ordering
if key not in self.sorters])
return grid
def make_default_renderers(self, renderers):
"""
Make the default set of column renderers for the grid.
@ -638,19 +597,6 @@ class Grid(object):
def actions_column_format(self, column_number, row_number, item):
return HTML.td(self.render_actions(item, row_number), class_='actions')
def render_grid(self, template='/grids/grid.mako', **kwargs):
context = kwargs
context['grid'] = self
context['request'] = self.request
grid_class = ''
if self.width == 'full':
grid_class = 'full'
elif self.width == 'half':
grid_class = 'half'
context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', ''))
context.setdefault('grid_attrs', {})
return render(template, context)
def get_default_filters(self):
"""
Returns the default set of filters provided by the grid.
@ -761,6 +707,9 @@ class Grid(object):
return query
return query.order_by(getattr(column, direction)())
sorter._class = class_
sorter._column = column
return sorter
def make_simple_sorter(self, key, foldcase=False):
@ -801,8 +750,12 @@ class Grid(object):
# initial default settings
settings = {}
if self.sortable:
settings['sortkey'] = self.default_sortkey
settings['sortdir'] = self.default_sortdir
if self.default_sortkey:
settings['sorters.length'] = 1
settings['sorters.1.key'] = self.default_sortkey
settings['sorters.1.dir'] = self.default_sortdir
else:
settings['sorters.length'] = 0
if self.pageable:
settings['pagesize'] = self.get_default_pagesize()
settings['page'] = self.default_page
@ -875,8 +828,12 @@ class Grid(object):
filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
filtr.value = settings['filter.{}.value'.format(filtr.key)]
if self.sortable:
self.sortkey = settings['sortkey']
self.sortdir = settings['sortdir']
self.active_sorters = []
for i in range(1, settings['sorters.length'] + 1):
self.active_sorters.append((
settings[f'sorters.{i}.key'],
settings[f'sorters.{i}.dir'],
))
if self.pageable:
self.pagesize = settings['pagesize']
self.page = settings['page']
@ -895,21 +852,36 @@ class Grid(object):
# anything...
session = Session()
if user not in session:
user = session.merge(user)
# TODO: pretty sure there is no need to *merge* here..
# but we shall see if any breakage happens maybe
#user = session.merge(user)
user = session.get(user.__class__, user.uuid)
# User defaults should have all or nothing, so just check one key.
key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
app = self.request.rattail_config.get_app()
return app.get_setting(Session(), key) is not None
# user defaults should be all or nothing, so just check one key
key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length'
if app.get_setting(session, key) is not None:
return True
# TODO: this is deprecated but should work its way out of the
# system in a little while (?)..then can remove this entirely
key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey'
if app.get_setting(session, key) is not None:
return True
return False
def apply_user_defaults(self, settings):
"""
Update the given settings dict with user defaults, if any exist.
"""
app = self.request.rattail_config.get_app()
session = Session()
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
def merge(key, normalize=lambda v: v):
skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
app = self.request.rattail_config.get_app()
value = app.get_setting(Session(), skey)
value = app.get_setting(session, f'{prefix}.{key}')
settings[key] = normalize(value)
if self.filterable:
@ -919,8 +891,52 @@ class Grid(object):
merge('filter.{}.value'.format(filtr.key))
if self.sortable:
merge('sortkey')
merge('sortdir')
# first clear existing settings for *sorting* only
# nb. this is because number of sort settings will vary
for key in list(settings):
if key.startswith('sorters.'):
del settings[key]
# check for *deprecated* settings, and use those if present
# TODO: obviously should stop this, but must wait until
# all old settings have been flushed out. which in the
# case of user-persisted settings, could be a while...
sortkey = app.get_setting(session, f'{prefix}.sortkey')
if sortkey:
settings['sorters.length'] = 1
settings['sorters.1.key'] = sortkey
settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir')
# nb. re-persist these user settings per new
# convention, so deprecated settings go away and we
# can remove this logic after a while..
app = self.request.rattail_config.get_app()
model = app.model
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
query = Session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like(f'{prefix}.sorters.%'),
model.Setting.name == f'{prefix}.sortkey',
model.Setting.name == f'{prefix}.sortdir'))
for setting in query.all():
Session.delete(setting)
Session.flush()
def persist(key):
app.save_setting(Session(),
f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}',
settings[key])
persist('sorters.length')
persist('sorters.1.key')
persist('sorters.1.dir')
else: # the future
merge('sorters.length', int)
for i in range(1, settings['sorters.length'] + 1):
merge(f'sorters.{i}.key')
merge(f'sorters.{i}.dir')
if self.pageable:
merge('pagesize', int)
@ -939,10 +955,16 @@ class Grid(object):
return True
elif type_ == 'sort':
# TODO: remove this eventually, but some links in the wild
# may still include these params, so leave it for now
for key in ['sortkey', 'sortdir']:
if key in self.request.GET:
return True
if 'sort1key' in self.request.GET:
return True
elif type_ == 'page':
for key in ['pagesize', 'page']:
if key in self.request.GET:
@ -956,10 +978,12 @@ class Grid(object):
"""
# session should have all or nothing, so just check a few keys which
# should be guaranteed present if anything has been stashed
for key in ['page', 'sortkey']:
if 'grid.{}.{}'.format(self.key, key) in self.request.session:
prefix = f'grid.{self.key}'
for key in ['page', 'sorters.length']:
if f'{prefix}.{key}' in self.request.session:
return True
return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session])
return any([key.startswith(f'{prefix}.filter')
for key in self.request.session])
def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
"""
@ -1044,8 +1068,46 @@ class Grid(object):
"""
if not self.sortable:
return
settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
if source == 'request':
# TODO: remove this eventually, but some links in the wild
# may still include these params, so leave it for now
if 'sortkey' in self.request.GET:
settings['sorters.length'] = 1
settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey')
settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
else: # the future
i = 1
while True:
skey = f'sort{i}key'
if skey in self.request.GET:
settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey)
settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir')
else:
break
i += 1
settings['sorters.length'] = i - 1
else: # session
# TODO: definitely will remove this, but leave it for now
# so it doesn't monkey with current user sessions when
# next upgrade happens. so, remove after all are upgraded
sortkey = self.get_setting(source, settings, 'sortkey')
if sortkey:
settings['sorters.length'] = 1
settings['sorters.1.key'] = sortkey
settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
else: # the future
settings['sorters.length'] = self.get_setting(source, settings,
'sorters.length', 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(source, settings, skey)
def update_page_settings(self, settings):
"""
@ -1100,8 +1162,40 @@ class Grid(object):
persist('filter.{}.value'.format(filtr.key))
if self.sortable:
persist('sortkey')
persist('sortdir')
# first clear existing settings for *sorting* only
# nb. this is because number of sort settings will vary
if to == 'defaults':
model = self.request.rattail_config.get_model()
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
query = Session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like(f'{prefix}.sorters.%'),
# TODO: remove these eventually,
# but probably should wait until
# all nodes have been upgraded for
# (quite) a while?
model.Setting.name == f'{prefix}.sortkey',
model.Setting.name == f'{prefix}.sortdir'))
for setting in query.all():
Session.delete(setting)
Session.flush()
else: # session
prefix = f'grid.{self.key}'
for key in list(self.request.session):
if key.startswith(f'{prefix}.sorters.'):
del self.request.session[key]
# TODO: definitely will remove these, but leave for
# now so they don't monkey with current user sessions
# when next upgrade happens. so, remove after all are
# upgraded
self.request.session.pop(f'{prefix}.sortkey', None)
self.request.session.pop(f'{prefix}.sortdir', None)
persist('sorters.length')
for i in range(1, settings['sorters.length'] + 1):
persist(f'sorters.{i}.key')
persist(f'sorters.{i}.dir')
if self.pageable:
persist('pagesize')
@ -1131,21 +1225,32 @@ class Grid(object):
"""
Sort the given query according to current settings, and return the result.
"""
# Cannot sort unless we know which column to sort by.
if not self.sortkey:
# bail if no sort settings
if not self.active_sorters:
return data
# Cannot sort unless we have a sort function.
sortfunc = self.sorters.get(self.sortkey)
if not sortfunc:
return data
# convert sort settings into a 'sortspec' for use with sa-filters
full_spec = []
for sortkey, sortdir in self.active_sorters:
sortfunc = self.sorters.get(sortkey)
if sortfunc:
spec = {
'sortkey': sortkey,
'model': sortfunc._class.__name__,
'field': sortfunc._column.name,
'direction': sortdir or 'asc',
}
# spec.sortkey = sortkey
full_spec.append(spec)
# We can provide a default sort direction though.
sortdir = getattr(self, 'sortdir', 'asc')
if self.sortkey in self.joiners and self.sortkey not in self.joined:
data = self.joiners[self.sortkey](data)
self.joined.add(self.sortkey)
return sortfunc(data, sortdir)
# apply joins needed for this sort spec
for spec in full_spec:
sortkey = spec['sortkey']
if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data)
self.joined.add(sortkey)
return apply_sort(data, full_spec)
def paginate_data(self, data):
"""
@ -1197,7 +1302,7 @@ class Grid(object):
data = self.pager
return data
def render_complete(self, template='/grids/complete.mako', **kwargs):
def render_complete(self, template='/grids/buefy.mako', **kwargs):
"""
Render the complete grid, including filters.
"""
@ -1717,5 +1822,5 @@ class URLMaker(object):
params = self.request.GET.copy()
params["page"] = page
params["partial"] = "1"
qs = urllib.parse.urlencode(params, True)
qs = urlencode(params, True)
return '{}?{}'.format(self.request.path, qs)