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.db import Session
class SortColumn(object):
def __init__(self, field_name, model_name=None):
self.field_name = field_name
self.model_name = model_name
from tailbone.util import SortColumn
class APIMasterView(APIView):

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.
"""
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)
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)
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)

View file

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

View file

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

View file

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