feat; add single-column sorting (frontend or backend) for grids
This commit is contained in:
parent
f21efbab9f
commit
58f7a862a2
10 changed files with 1215 additions and 100 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -3,11 +3,20 @@
|
|||
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
||||
<${b}-table :data="data"
|
||||
:loading="loading"
|
||||
|
||||
narrowed
|
||||
hoverable
|
||||
icon-pack="fas"
|
||||
|
||||
## sorting
|
||||
% if grid.sortable:
|
||||
## nb. buefy only supports *one* default sorter
|
||||
:default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
|
||||
% if grid.sort_on_backend:
|
||||
backend-sorting
|
||||
@sort="onSort"
|
||||
% endif
|
||||
% endif
|
||||
|
||||
## paging
|
||||
% if grid.paginated:
|
||||
paginated
|
||||
|
@ -26,6 +35,7 @@
|
|||
<${b}-table-column field="${column['field']}"
|
||||
label="${column['label']}"
|
||||
v-slot="props"
|
||||
:sortable="${json.dumps(column.get('sortable', False))|n}"
|
||||
cell-class="c_${column['field']}">
|
||||
% if grid.is_linked(column['field']):
|
||||
<a :href="props.row._action_url_view"
|
||||
|
@ -107,6 +117,11 @@
|
|||
data: ${grid.vue_component}CurrentData,
|
||||
loading: false,
|
||||
|
||||
## sorting
|
||||
% if grid.sortable:
|
||||
sorters: ${json.dumps(grid.active_sorters)|n},
|
||||
% endif
|
||||
|
||||
## paging
|
||||
% if grid.paginated:
|
||||
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
|
||||
|
@ -151,21 +166,24 @@
|
|||
},
|
||||
|
||||
getBasicParams() {
|
||||
return {
|
||||
const params = {
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
pagesize: this.perPage,
|
||||
page: this.currentPage,
|
||||
% endif
|
||||
}
|
||||
% if grid.sortable and grid.sort_on_backend:
|
||||
for (let i = 1; i <= this.sorters.length; i++) {
|
||||
params['sort'+i+'key'] = this.sorters[i-1].key
|
||||
params['sort'+i+'dir'] = this.sorters[i-1].dir
|
||||
}
|
||||
% endif
|
||||
return params
|
||||
},
|
||||
|
||||
async fetchData(params, success, failure) {
|
||||
async fetchData() {
|
||||
|
||||
if (params === undefined || params === null) {
|
||||
params = new URLSearchParams(this.getBasicParams())
|
||||
} else {
|
||||
params = new URLSearchParams(params)
|
||||
}
|
||||
let params = new URLSearchParams(this.getBasicParams())
|
||||
if (!params.has('partial')) {
|
||||
params.append('partial', true)
|
||||
}
|
||||
|
@ -180,9 +198,6 @@
|
|||
this.pagerStats = response.data.pager_stats
|
||||
% endif
|
||||
this.loading = false
|
||||
if (success) {
|
||||
success()
|
||||
}
|
||||
} else {
|
||||
this.$buefy.toast.open({
|
||||
message: data.error,
|
||||
|
@ -190,9 +205,6 @@
|
|||
duration: 2000, // 4 seconds
|
||||
})
|
||||
this.loading = false
|
||||
if (failure) {
|
||||
failure()
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -201,13 +213,29 @@
|
|||
this.pagerStats = {}
|
||||
% endif
|
||||
this.loading = false
|
||||
if (failure) {
|
||||
failure()
|
||||
}
|
||||
throw error
|
||||
})
|
||||
},
|
||||
|
||||
% if grid.sortable and grid.sort_on_backend:
|
||||
|
||||
onSort(field, order, event) {
|
||||
|
||||
## nb. buefy passes field name; oruga passes field object
|
||||
% if request.use_oruga:
|
||||
field = field.field
|
||||
% endif
|
||||
|
||||
// sort by single column only
|
||||
this.sorters = [{key: field, dir: order}]
|
||||
|
||||
// nb. always reset to first page when sorting changes
|
||||
this.currentPage = 1
|
||||
this.fetchData()
|
||||
},
|
||||
|
||||
% endif
|
||||
|
||||
% if grid.paginated:
|
||||
|
||||
% if grid.paginate_on_backend:
|
||||
|
|
|
@ -181,6 +181,37 @@ class MasterView(View):
|
|||
|
||||
This is optional; see also :meth:`get_grid_columns()`.
|
||||
|
||||
.. attribute:: sortable
|
||||
|
||||
Boolean indicating whether the grid for the :meth:`index()`
|
||||
view should allow sorting of data. Default is ``True``.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.sortable` flag.
|
||||
|
||||
See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
|
||||
|
||||
.. attribute:: sort_on_backend
|
||||
|
||||
Boolean indicating whether the grid data for the
|
||||
:meth:`index()` view should be sorted on the backend. Default
|
||||
is ``True``.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
|
||||
|
||||
Only relevant if :attr:`sortable` is true.
|
||||
|
||||
.. attribute:: sort_defaults
|
||||
|
||||
Optional list of default sorting info. Applicable for both
|
||||
frontend and backend sorting.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
|
||||
|
||||
Only relevant if :attr:`sortable` is true.
|
||||
|
||||
.. attribute:: paginated
|
||||
|
||||
Boolean indicating whether the grid data for the
|
||||
|
@ -246,6 +277,9 @@ class MasterView(View):
|
|||
# features
|
||||
listable = True
|
||||
has_grid = True
|
||||
sortable = True
|
||||
sort_on_backend = True
|
||||
sort_defaults = None
|
||||
paginated = True
|
||||
paginate_on_backend = True
|
||||
creatable = True
|
||||
|
@ -1089,6 +1123,9 @@ class MasterView(View):
|
|||
|
||||
kwargs['actions'] = actions
|
||||
|
||||
kwargs.setdefault('sortable', self.sortable)
|
||||
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
|
||||
kwargs.setdefault('sort_defaults', self.sort_defaults)
|
||||
kwargs.setdefault('paginated', self.paginated)
|
||||
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
|
||||
|
||||
|
@ -1136,8 +1173,7 @@ class MasterView(View):
|
|||
"""
|
||||
query = self.get_query(session=session)
|
||||
if query:
|
||||
return query.all()
|
||||
|
||||
return query
|
||||
return []
|
||||
|
||||
def get_query(self, session=None):
|
||||
|
|
|
@ -45,6 +45,7 @@ class PersonView(MasterView):
|
|||
model_class = Person
|
||||
model_title_plural = "People"
|
||||
route_prefix = 'people'
|
||||
sort_defaults = 'full_name'
|
||||
|
||||
grid_columns = [
|
||||
'full_name',
|
||||
|
@ -53,13 +54,6 @@ class PersonView(MasterView):
|
|||
'last_name',
|
||||
]
|
||||
|
||||
# TODO: master should handle this, possibly via configure_form()
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
model = self.app.model
|
||||
query = super().get_query(session=session)
|
||||
return query.order_by(model.Person.full_name)
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
|
|
@ -149,20 +149,34 @@ class SettingView(MasterView):
|
|||
"""
|
||||
model_class = Setting
|
||||
model_title = "Raw Setting"
|
||||
sort_defaults = 'name'
|
||||
|
||||
# TODO: master should handle this, possibly via configure_form()
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
model = self.app.model
|
||||
query = super().get_query(session=session)
|
||||
return query.order_by(model.Setting.name)
|
||||
|
||||
# TODO: master should handle this (per column nullable)
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
|
||||
# name
|
||||
f.set_validator('name', self.unique_name)
|
||||
|
||||
# value
|
||||
# TODO: master should handle this (per column nullable)
|
||||
f.set_required('value', False)
|
||||
|
||||
def unique_name(self, node, value):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
query = session.query(model.Setting)\
|
||||
.filter(model.Setting.name == value)
|
||||
|
||||
if self.editing:
|
||||
name = self.request.matchdict['name']
|
||||
query = query.filter(model.Setting.name != name)
|
||||
|
||||
if query.count():
|
||||
node.raise_invalid("Setting name must be unique")
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue