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

@ -33,13 +33,7 @@ from cornice import resource, Service
from tailbone.api import APIView, api from tailbone.api import APIView, api
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import SortColumn
class SortColumn(object):
def __init__(self, field_name, model_name=None):
self.field_name = field_name
self.model_name = model_name
class APIMasterView(APIView): class APIMasterView(APIView):

View file

@ -24,12 +24,13 @@
Core Grid Classes Core Grid Classes
""" """
from urllib.parse import urlencode
import warnings import warnings
import logging import logging
from six.moves import urllib
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sa_filters import apply_sort
from rattail.db.types import GPCType from rattail.db.types import GPCType
from rattail.util import prettify, pretty_boolean, pretty_quantity from rattail.util import prettify, pretty_boolean, pretty_quantity
@ -552,48 +553,6 @@ class Grid(object):
return self.url(obj) return self.url(obj)
return self.url 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): def make_default_renderers(self, renderers):
""" """
Make the default set of column renderers for the grid. 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): def actions_column_format(self, column_number, row_number, item):
return HTML.td(self.render_actions(item, row_number), class_='actions') 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): def get_default_filters(self):
""" """
Returns the default set of filters provided by the grid. Returns the default set of filters provided by the grid.
@ -761,6 +707,9 @@ class Grid(object):
return query return query
return query.order_by(getattr(column, direction)()) return query.order_by(getattr(column, direction)())
sorter._class = class_
sorter._column = column
return sorter return sorter
def make_simple_sorter(self, key, foldcase=False): def make_simple_sorter(self, key, foldcase=False):
@ -801,8 +750,12 @@ class Grid(object):
# initial default settings # initial default settings
settings = {} settings = {}
if self.sortable: if self.sortable:
settings['sortkey'] = self.default_sortkey if self.default_sortkey:
settings['sortdir'] = self.default_sortdir 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: if self.pageable:
settings['pagesize'] = self.get_default_pagesize() settings['pagesize'] = self.get_default_pagesize()
settings['page'] = self.default_page settings['page'] = self.default_page
@ -875,8 +828,12 @@ class Grid(object):
filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
filtr.value = settings['filter.{}.value'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)]
if self.sortable: if self.sortable:
self.sortkey = settings['sortkey'] self.active_sorters = []
self.sortdir = settings['sortdir'] 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: if self.pageable:
self.pagesize = settings['pagesize'] self.pagesize = settings['pagesize']
self.page = settings['page'] self.page = settings['page']
@ -895,21 +852,36 @@ class Grid(object):
# anything... # anything...
session = Session() session = Session()
if user not in 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() 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): def apply_user_defaults(self, settings):
""" """
Update the given settings dict with user defaults, if any exist. Update the given settings dict with user defaults, if any exist.
""" """
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() app = self.request.rattail_config.get_app()
value = app.get_setting(Session(), skey) session = Session()
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
def merge(key, normalize=lambda v: v):
value = app.get_setting(session, f'{prefix}.{key}')
settings[key] = normalize(value) settings[key] = normalize(value)
if self.filterable: if self.filterable:
@ -919,8 +891,52 @@ class Grid(object):
merge('filter.{}.value'.format(filtr.key)) merge('filter.{}.value'.format(filtr.key))
if self.sortable: 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: if self.pageable:
merge('pagesize', int) merge('pagesize', int)
@ -939,10 +955,16 @@ class Grid(object):
return True return True
elif type_ == 'sort': 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']: for key in ['sortkey', 'sortdir']:
if key in self.request.GET: if key in self.request.GET:
return True return True
if 'sort1key' in self.request.GET:
return True
elif type_ == 'page': elif type_ == 'page':
for key in ['pagesize', 'page']: for key in ['pagesize', 'page']:
if key in self.request.GET: 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 # session should have all or nothing, so just check a few keys which
# should be guaranteed present if anything has been stashed # should be guaranteed present if anything has been stashed
for key in ['page', 'sortkey']: prefix = f'grid.{self.key}'
if 'grid.{}.{}'.format(self.key, key) in self.request.session: for key in ['page', 'sorters.length']:
if f'{prefix}.{key}' in self.request.session:
return True 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): def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
""" """
@ -1044,8 +1068,46 @@ class Grid(object):
""" """
if not self.sortable: if not self.sortable:
return 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): def update_page_settings(self, settings):
""" """
@ -1100,8 +1162,40 @@ class Grid(object):
persist('filter.{}.value'.format(filtr.key)) persist('filter.{}.value'.format(filtr.key))
if self.sortable: 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: if self.pageable:
persist('pagesize') persist('pagesize')
@ -1131,21 +1225,32 @@ class Grid(object):
""" """
Sort the given query according to current settings, and return the result. Sort the given query according to current settings, and return the result.
""" """
# Cannot sort unless we know which column to sort by. # bail if no sort settings
if not self.sortkey: if not self.active_sorters:
return data return data
# Cannot sort unless we have a sort function. # convert sort settings into a 'sortspec' for use with sa-filters
sortfunc = self.sorters.get(self.sortkey) full_spec = []
if not sortfunc: for sortkey, sortdir in self.active_sorters:
return data 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. # apply joins needed for this sort spec
sortdir = getattr(self, 'sortdir', 'asc') for spec in full_spec:
if self.sortkey in self.joiners and self.sortkey not in self.joined: sortkey = spec['sortkey']
data = self.joiners[self.sortkey](data) if sortkey in self.joiners and sortkey not in self.joined:
self.joined.add(self.sortkey) data = self.joiners[sortkey](data)
return sortfunc(data, sortdir) self.joined.add(sortkey)
return apply_sort(data, full_spec)
def paginate_data(self, data): def paginate_data(self, data):
""" """
@ -1197,7 +1302,7 @@ class Grid(object):
data = self.pager data = self.pager
return data 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. Render the complete grid, including filters.
""" """
@ -1717,5 +1822,5 @@ class URLMaker(object):
params = self.request.GET.copy() params = self.request.GET.copy()
params["page"] = page params["page"] = page
params["partial"] = "1" params["partial"] = "1"
qs = urllib.parse.urlencode(params, True) qs = urlencode(params, True)
return '{}?{}'.format(self.request.path, qs) return '{}?{}'.format(self.request.path, qs)

View file

@ -202,7 +202,7 @@
% endif % endif
% if grid.sortable: % if grid.sortable:
:default-sort="[sortField, sortOrder]" :default-sort="sortingPriority[0]"
backend-sorting backend-sorting
@sort="onSort" @sort="onSort"
% endif % endif
@ -352,8 +352,9 @@
firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n},
lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
sortField: ${json.dumps(grid.sortkey if grid.sortable else None)|n}, % if grid.sortable:
sortOrder: ${json.dumps(grid.sortdir if grid.sortable else None)|n}, sortingPriority: ${json.dumps(grid.active_sorters)|n},
% endif
## filterable: ${json.dumps(grid.filterable)|n}, ## filterable: ${json.dumps(grid.filterable)|n},
filters: ${json.dumps(filters_data if grid.filterable else None)|n}, filters: ${json.dumps(filters_data if grid.filterable else None)|n},
@ -454,8 +455,10 @@
getBasicParams() { getBasicParams() {
let params = {} let params = {}
% if grid.sortable: % if grid.sortable:
params.sortkey = this.sortField for (let i = 1; i <= this.sortingPriority.length; i++) {
params.sortdir = this.sortOrder params['sort'+i+'key'] = this.sortingPriority[i-1][0]
params['sort'+i+'dir'] = this.sortingPriority[i-1][1]
}
% endif % endif
% if grid.pageable: % if grid.pageable:
params.pagesize = this.perPage params.pagesize = this.perPage
@ -535,8 +538,7 @@
}, },
onSort(field, order) { onSort(field, order) {
this.sortField = field this.sortingPriority = [[field, order]]
this.sortOrder = order
// always reset to first page when changing sort options // always reset to first page when changing sort options
// TODO: i mean..right? would we ever not want that? // TODO: i mean..right? would we ever not want that?
this.currentPage = 1 this.currentPage = 1

View file

@ -1,38 +0,0 @@
## -*- coding: utf-8 -*-
<div class="grid-wrapper">
<table class="grid-header">
<tbody>
<tr>
<td class="filters" rowspan="2">
% if grid.filterable:
${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
% endif
</td>
<td class="menu">
% if context_menu:
<ul id="context-menu">
${context_menu|n}
</ul>
% endif
</td>
</tr>
<tr>
<td class="tools">
% if tools:
<div class="grid-tools">
${tools|n}
</div><!-- grid-tools -->
% endif
</td>
</tr>
</tbody>
</table><!-- grid-header -->
${grid.render_grid()|n}
</div><!-- grid-wrapper -->

View file

@ -1,21 +0,0 @@
## -*- coding: utf-8; -*-
<div class="grid ${grid_class}" data-delete-speedbump="${'true' if grid.delete_speedbump else 'false'}" ${h.HTML.render_attrs(grid_attrs)}>
<table>
${grid.make_webhelpers_grid()}
</table>
% if grid.pageable and grid.pager:
<div class="pager">
<p class="showing">
${"showing {} thru {} of {:,d}".format(grid.pager.first_item, grid.pager.last_item, grid.pager.item_count)}
% if grid.pager.page_count > 1:
${"(page {} of {:,d})".format(grid.pager.page, grid.pager.page_count)}
% endif
</p>
<p class="page-links">
${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())}
per page&nbsp;
${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev')|n}
</p>
</div>
% endif
</div>

View file

@ -44,6 +44,17 @@ from webhelpers2.html import HTML, tags
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class SortColumn(object):
"""
Generic representation of a sort column, for use with sorting grid
data as well as with API.
"""
def __init__(self, field_name, model_name=None):
self.field_name = field_name
self.model_name = model_name
def get_csrf_token(request): def get_csrf_token(request):
""" """
Convenience function to retrieve the effective CSRF token for the given Convenience function to retrieve the effective CSRF token for the given

View file

@ -476,36 +476,6 @@ class CustomerView(MasterView):
items.append(HTML.tag('li', c=[link])) items.append(HTML.tag('li', c=[link]))
return HTML.tag('ul', c=items) return HTML.tag('ul', c=items)
# TODO: remove if no longer used
def render_people_removable(self, customer, field):
people = customer.people
if not people:
return ""
route_prefix = self.get_route_prefix()
permission_prefix = self.get_permission_prefix()
view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid)
actions = [
grids.GridAction('view', icon='zoomin', url=view_url),
]
if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix),
uuid=customer.uuid, person_uuid=p.uuid)
actions.append(
grids.GridAction('detach', icon='trash', url=url))
columns = ['first_name', 'last_name', 'display_name']
g = grids.Grid(
key='{}.people'.format(route_prefix),
data=customer.people,
columns=columns,
labels={'display_name': "Full Name"},
url=lambda p: self.request.route_url('people.view', uuid=p.uuid),
linked_columns=columns,
main_actions=actions)
return HTML.literal(g.render_grid())
def render_shoppers(self, customer, field): def render_shoppers(self, customer, field):
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
permission_prefix = self.get_permission_prefix() permission_prefix = self.get_permission_prefix()

View file

@ -340,11 +340,9 @@ class MasterView(View):
if grid.pageable and hasattr(grid, 'pager'): if grid.pageable and hasattr(grid, 'pager'):
self.first_visible_grid_index = grid.pager.first_item self.first_visible_grid_index = grid.pager.first_item
# return grid only, if partial page was requested # return grid data only, if partial page was requested
if self.request.params.get('partial'): if self.request.params.get('partial'):
# render grid data only, as JSON return self.json_response(grid.get_buefy_data())
return render_to_response('json', grid.get_buefy_data(),
request=self.request)
context = { context = {
'grid': grid, 'grid': grid,
@ -1156,8 +1154,7 @@ class MasterView(View):
# return grid only, if partial page was requested # return grid only, if partial page was requested
if self.request.params.get('partial'): if self.request.params.get('partial'):
# render grid data only, as JSON # render grid data only, as JSON
return render_to_response('json', grid.get_buefy_data(), return self.json_response(grid.get_buefy_data())
request=self.request)
context = { context = {
'instance': instance, 'instance': instance,
@ -1284,8 +1281,7 @@ class MasterView(View):
# return grid only, if partial page was requested # return grid only, if partial page was requested
if self.request.params.get('partial'): if self.request.params.get('partial'):
# render grid data only, as JSON # render grid data only, as JSON
return render_to_response('json', grid.get_buefy_data(), return self.json_response(grid.get_buefy_data())
request=self.request)
return self.render_to_response('versions', { return self.render_to_response('versions', {
'instance': instance, 'instance': instance,

View file

@ -461,7 +461,8 @@ class MemberEquityPaymentView(MasterView):
g.set_renderer(field, self.render_member_key) g.set_renderer(field, self.render_member_key)
g.set_filter(field, attr, g.set_filter(field, attr,
label=self.get_member_key_label(), label=self.get_member_key_label(),
default_active=True) default_active=True,
default_verb='equal')
g.set_sorter(field, attr) g.set_sorter(field, attr)
# member (name) # member (name)