1
0
Fork 0

feat; add single-column sorting (frontend or backend) for grids

This commit is contained in:
Lance Edgar 2024-08-18 14:05:10 -05:00
parent f21efbab9f
commit 58f7a862a2
10 changed files with 1215 additions and 100 deletions

View file

@ -32,6 +32,7 @@ requires-python = ">= 3.8"
dependencies = [ dependencies = [
"ColanderAlchemy", "ColanderAlchemy",
"paginate", "paginate",
"paginate_sqlalchemy",
"pyramid>=2", "pyramid>=2",
"pyramid_beaker", "pyramid_beaker",
"pyramid_deform", "pyramid_deform",

View file

@ -26,6 +26,8 @@ Grids Library
The ``wuttaweb.grids`` namespace contains the following: The ``wuttaweb.grids`` namespace contains the following:
* :class:`~wuttaweb.grids.base.Grid` * :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

View file

@ -27,10 +27,13 @@ Base grid classes
import functools import functools
import json import json
import logging import logging
from collections import namedtuple
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm
import paginate import paginate
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
@ -41,6 +44,13 @@ from wuttaweb.util import FieldList, get_model_fields, make_json_safe
log = logging.getLogger(__name__) 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: class Grid:
""" """
Base class for all grids. Base class for all grids.
@ -116,6 +126,52 @@ class Grid:
See also :meth:`set_link()` and :meth:`is_linked()`. 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 .. attribute:: paginated
Boolean indicating whether the grid data should be paginated Boolean indicating whether the grid data should be paginated
@ -175,6 +231,10 @@ class Grid:
renderers={}, renderers={},
actions=[], actions=[],
linked_columns=[], linked_columns=[],
sortable=False,
sort_on_backend=True,
sorters=None,
sort_defaults=None,
paginated=False, paginated=False,
paginate_on_backend=True, paginate_on_backend=True,
pagesize_options=None, pagesize_options=None,
@ -196,6 +256,35 @@ class Grid:
self.set_columns(columns or self.get_columns()) 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.paginated = paginated
self.paginate_on_backend = paginate_on_backend self.paginate_on_backend = paginate_on_backend
self.pagesize_options = pagesize_options or self.get_pagesize_options() self.pagesize_options = pagesize_options or self.get_pagesize_options()
@ -402,6 +491,252 @@ class Grid:
return True return True
return False 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 # paging methods
############################## ##############################
@ -462,19 +797,17 @@ class Grid:
def load_settings(self, store=True): def load_settings(self, store=True):
""" """
Load all effective settings for the grid, from the following Load all effective settings for the grid.
places:
* request params If the request GET params (query string) contains grid
* user session settings, they are used; otherwise the settings are loaded
from user session.
The first value found for a given setting will be applied to
the grid.
.. note:: .. note::
As of now, "pagination" settings are the only type As of now, "sorting" and "pagination" settings are the only
supported by this logic. Filter/sort coming soon... type supported by this logic. Settings for "filtering"
coming soon...
The overall logic for this method is as follows: The overall logic for this method is as follows:
@ -483,45 +816,143 @@ class Grid:
* optionally save settings to user session * optionally save settings to user session
Saving the settings to user session will allow the grid to 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 :param store: Whether the collected settings should be saved
should then be saved to the user session. to the user session.
""" """
# initial default settings # initial default settings
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: if self.paginated and self.paginate_on_backend:
settings['pagesize'] = self.pagesize settings['pagesize'] = self.pagesize
settings['page'] = self.page settings['page'] = self.page
# grab settings from request and/or user session # update settings dict based on what we find in the request
if self.paginated and self.paginate_on_backend: # 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) self.update_page_settings(settings)
else: else:
# no settings were found in request or user session, so # nothing found in request, so nothing new to save
# nothing needs to be saved
store = False 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 # maybe store settings in user session, for next time
if store: if store:
self.persist_settings(settings) self.persist_settings(settings, dest='session')
# update ourself to reflect settings # 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: if self.paginated and self.paginate_on_backend:
self.pagesize = settings['pagesize'] self.pagesize = settings['pagesize']
self.page = settings['page'] 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 return True
elif typ == 'page':
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
return False 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): 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 # update the settings dict from request and/or user session
# pagesize # pagesize
@ -544,17 +975,42 @@ class Grid:
if page is not None: if page is not None:
settings['page'] = int(page) settings['page'] = int(page)
def persist_settings(self, settings): def persist_settings(self, settings, dest=None):
""" """ """ """
model = self.app.model if dest not in ('session',):
session = Session() raise ValueError(f"invalid dest identifier: {dest}")
# func to save a setting value to user session # func to save a setting value to user session
def persist(key, value=lambda k: settings.get(k)): def persist(key, value=lambda k: settings.get(k)):
assert dest == 'session'
skey = f'grid.{self.key}.{key}' skey = f'grid.{self.key}.{key}'
self.request.session[skey] = value(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: if self.paginated and self.paginate_on_backend:
# save to dest
persist('pagesize') persist('pagesize')
persist('page') persist('page')
@ -579,12 +1035,42 @@ class Grid:
""" """
data = self.data or [] 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: if self.paginated and self.paginate_on_backend:
self.pager = self.paginate_data(data) self.pager = self.paginate_data(data)
data = self.pager data = self.pager
return data 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): def paginate_data(self, data):
""" """
Apply pagination to the given data set, based on grid settings. 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()`. This method is called by :meth:`get_visible_data()`.
""" """
# nb. empty data should never have a page number > 1 if isinstance(data, orm.Query):
if not data: pager = SqlalchemyOrmPage(data,
self.page = 1 items_per_page=self.pagesize,
# nb. must also update user session if applicable 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' key = f'grid.{self.key}.page'
if key in self.request.session: if key in self.request.session:
self.request.session[key] = self.page self.request.session[key] = self.page
pager = paginate.Page(data, # and re-make the pager just to be safe (?)
items_per_page=self.pagesize, pager = self.paginate_data(data)
page=self.page)
return pager return pager
############################## ##############################
@ -682,6 +1178,7 @@ class Grid:
columns.append({ columns.append({
'field': name, 'field': name,
'label': self.get_label(name), 'label': self.get_label(name),
'sortable': self.is_sortable(name),
}) })
return columns return columns

View file

@ -3,11 +3,20 @@
<script type="text/x-template" id="${grid.vue_tagname}-template"> <script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data" <${b}-table :data="data"
:loading="loading" :loading="loading"
narrowed narrowed
hoverable hoverable
icon-pack="fas" 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 ## paging
% if grid.paginated: % if grid.paginated:
paginated paginated
@ -26,6 +35,7 @@
<${b}-table-column field="${column['field']}" <${b}-table-column field="${column['field']}"
label="${column['label']}" label="${column['label']}"
v-slot="props" v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
cell-class="c_${column['field']}"> cell-class="c_${column['field']}">
% if grid.is_linked(column['field']): % if grid.is_linked(column['field']):
<a :href="props.row._action_url_view" <a :href="props.row._action_url_view"
@ -107,6 +117,11 @@
data: ${grid.vue_component}CurrentData, data: ${grid.vue_component}CurrentData,
loading: false, loading: false,
## sorting
% if grid.sortable:
sorters: ${json.dumps(grid.active_sorters)|n},
% endif
## paging ## paging
% if grid.paginated: % if grid.paginated:
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
@ -151,21 +166,24 @@
}, },
getBasicParams() { getBasicParams() {
return { const params = {
% if grid.paginated and grid.paginate_on_backend: % if grid.paginated and grid.paginate_on_backend:
pagesize: this.perPage, pagesize: this.perPage,
page: this.currentPage, page: this.currentPage,
% endif % 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) { let params = new URLSearchParams(this.getBasicParams())
params = new URLSearchParams(this.getBasicParams())
} else {
params = new URLSearchParams(params)
}
if (!params.has('partial')) { if (!params.has('partial')) {
params.append('partial', true) params.append('partial', true)
} }
@ -180,9 +198,6 @@
this.pagerStats = response.data.pager_stats this.pagerStats = response.data.pager_stats
% endif % endif
this.loading = false this.loading = false
if (success) {
success()
}
} else { } else {
this.$buefy.toast.open({ this.$buefy.toast.open({
message: data.error, message: data.error,
@ -190,9 +205,6 @@
duration: 2000, // 4 seconds duration: 2000, // 4 seconds
}) })
this.loading = false this.loading = false
if (failure) {
failure()
}
} }
}) })
.catch((error) => { .catch((error) => {
@ -201,13 +213,29 @@
this.pagerStats = {} this.pagerStats = {}
% endif % endif
this.loading = false this.loading = false
if (failure) {
failure()
}
throw error 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.paginated:
% if grid.paginate_on_backend: % if grid.paginate_on_backend:

View file

@ -181,6 +181,37 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`. 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 .. attribute:: paginated
Boolean indicating whether the grid data for the Boolean indicating whether the grid data for the
@ -246,6 +277,9 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True has_grid = True
sortable = True
sort_on_backend = True
sort_defaults = None
paginated = True paginated = True
paginate_on_backend = True paginate_on_backend = True
creatable = True creatable = True
@ -1089,6 +1123,9 @@ class MasterView(View):
kwargs['actions'] = actions 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('paginated', self.paginated)
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend) kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
@ -1136,8 +1173,7 @@ class MasterView(View):
""" """
query = self.get_query(session=session) query = self.get_query(session=session)
if query: if query:
return query.all() return query
return [] return []
def get_query(self, session=None): def get_query(self, session=None):

View file

@ -45,6 +45,7 @@ class PersonView(MasterView):
model_class = Person model_class = Person
model_title_plural = "People" model_title_plural = "People"
route_prefix = 'people' route_prefix = 'people'
sort_defaults = 'full_name'
grid_columns = [ grid_columns = [
'full_name', 'full_name',
@ -53,13 +54,6 @@ class PersonView(MasterView):
'last_name', '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): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)

View file

@ -149,20 +149,34 @@ class SettingView(MasterView):
""" """
model_class = Setting model_class = Setting
model_title = "Raw 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): def configure_form(self, f):
""" """ """ """
super().configure_form(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) 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -3,11 +3,13 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import orm
from paginate import Page from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid import testing from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base from wuttaweb.grids import base as mod
from wuttaweb.forms import FieldList from wuttaweb.forms import FieldList
from tests.util import WebTestCase from tests.util import WebTestCase
@ -15,7 +17,7 @@ from tests.util import WebTestCase
class TestGrid(WebTestCase): class TestGrid(WebTestCase):
def make_grid(self, request=None, **kwargs): def make_grid(self, request=None, **kwargs):
return base.Grid(request or self.request, **kwargs) return mod.Grid(request or self.request, **kwargs)
def test_constructor(self): def test_constructor(self):
@ -30,6 +32,50 @@ class TestGrid(WebTestCase):
self.assertIsInstance(grid.columns, FieldList) self.assertIsInstance(grid.columns, FieldList)
self.assertEqual(grid.columns, ['foo', 'bar']) self.assertEqual(grid.columns, ['foo', 'bar'])
def test_constructor_sorting(self):
model = self.app.model
# defaults, not sortable
grid = self.make_grid()
self.assertFalse(grid.sortable)
self.assertTrue(grid.sort_on_backend)
self.assertEqual(grid.sorters, {})
self.assertEqual(grid.sort_defaults, [])
# defaults, sortable
grid = self.make_grid(sortable=True)
self.assertTrue(grid.sortable)
self.assertTrue(grid.sort_on_backend)
self.assertEqual(grid.sorters, {})
self.assertEqual(grid.sort_defaults, [])
# sorters may be pre-populated
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(len(grid.sorters), 2)
self.assertIn('name', grid.sorters)
self.assertIn('value', grid.sorters)
self.assertEqual(grid.sort_defaults, [])
# sort defaults as str
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults='name')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
# sort defaults as tuple
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults=('name', 'desc'))
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# sort defaults as list w/ single tuple
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults=[('name', 'desc')])
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# sort defaults as list w/ multiple
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults=[('name', 'desc'), ('value', 'asc')])
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
def test_vue_tagname(self): def test_vue_tagname(self):
grid = self.make_grid() grid = self.make_grid()
self.assertEqual(grid.vue_tagname, 'wutta-grid') self.assertEqual(grid.vue_tagname, 'wutta-grid')
@ -174,6 +220,9 @@ class TestGrid(WebTestCase):
############################## ##############################
def test_load_settings(self): def test_load_settings(self):
model = self.app.model
# nb. first use a paging grid
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
pagesize=20, page=1) pagesize=20, page=1)
@ -196,19 +245,169 @@ class TestGrid(WebTestCase):
grid.load_settings() grid.load_settings()
self.assertFalse(grid.paginated) self.assertFalse(grid.paginated)
# nb. next use a sorting grid
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True)
# settings are loaded, applied, saved
self.assertEqual(grid.sort_defaults, [])
self.assertFalse(hasattr(grid, 'active_sorters'))
self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
# can skip the saving step
self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
grid.load_settings(store=False)
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
# no error for non-sortable grid
grid = self.make_grid(key='foo', sortable=False)
grid.load_settings()
self.assertFalse(grid.sortable)
# with sort defaults
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_on_backend=True, sort_defaults='name')
self.assertFalse(hasattr(grid, 'active_sorters'))
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
# with multi-column sort defaults
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_on_backend=True)
grid.sort_defaults = [
mod.SortInfo('name', 'asc'),
mod.SortInfo('value', 'desc'),
]
self.assertFalse(hasattr(grid, 'active_sorters'))
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
# load settings from session when nothing is in request
self.request.GET = {}
self.request.session.invalidate()
self.assertNotIn('grid.settings.sorters.length', self.request.session)
self.request.session['grid.settings.sorters.length'] = 1
self.request.session['grid.settings.sorters.1.key'] = 'name'
self.request.session['grid.settings.sorters.1.dir'] = 'desc'
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True,
paginated=True, paginate_on_backend=True)
self.assertFalse(hasattr(grid, 'active_sorters'))
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
def test_request_has_settings(self): def test_request_has_settings(self):
grid = self.make_grid(key='foo') grid = self.make_grid(key='foo')
self.assertFalse(grid.request_has_settings()) # paging
self.assertFalse(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'pagesize': '20'}): with patch.object(self.request, 'GET', new={'pagesize': '20'}):
self.assertTrue(grid.request_has_settings()) self.assertTrue(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'page': '1'}): with patch.object(self.request, 'GET', new={'page': '1'}):
self.assertTrue(grid.request_has_settings()) self.assertTrue(grid.request_has_settings('page'))
# sorting
self.assertFalse(grid.request_has_settings('sort'))
with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
self.assertTrue(grid.request_has_settings('sort'))
def test_get_setting(self):
grid = self.make_grid(key='foo')
settings = {}
# default is null
value = grid.get_setting(settings, 'pagesize')
self.assertIsNone(value)
# can read value from user session
self.request.session['grid.foo.pagesize'] = 15
value = grid.get_setting(settings, 'pagesize', src='session')
self.assertEqual(value, 15)
# string value not normalized
self.request.session['grid.foo.pagesize'] = '15'
value = grid.get_setting(settings, 'pagesize', src='session')
self.assertEqual(value, '15')
self.assertNotEqual(value, 15)
# but can be normalized
self.request.session['grid.foo.pagesize'] = '15'
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 15)
# can read value from request
self.request.GET = {'pagesize': '25'}
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
self.assertEqual(value, 25)
# null when normalization fails
self.request.GET = {'pagesize': 'invalid'}
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
self.assertIsNone(value)
# reset
del self.request.session['grid.foo.pagesize']
self.request.GET = {}
# value can come from provided settings
settings['pagesize'] = '35'
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 35)
def test_update_sort_settings(self):
model = self.app.model
# nothing happens if not sortable
grid = self.make_grid(key='settings', model_class=model.Setting)
settings = {'sorters.length': 0}
self.request.session['grid.settings.sorters.length'] = 1
self.request.session['grid.settings.sorters.1.key'] = 'name'
self.request.session['grid.settings.sorters.1.dir'] = 'asc'
grid.update_sort_settings(settings, src='session')
self.assertEqual(settings['sorters.length'], 0)
# nb. now use a sortable grid
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True)
# settings are updated from session
settings = {'sorters.length': 1, 'sorters.1.key': 'name', 'sorters.1.dir': 'asc'}
self.request.session['grid.settings.sorters.length'] = 1
self.request.session['grid.settings.sorters.1.key'] = 'name'
self.request.session['grid.settings.sorters.1.dir'] = 'asc'
grid.update_sort_settings(settings, src='session')
self.assertEqual(settings['sorters.length'], 1)
self.assertEqual(settings['sorters.1.key'], 'name')
self.assertEqual(settings['sorters.1.dir'], 'asc')
# settings are updated from request
self.request.GET = {'sort1key': 'value', 'sort1dir': 'desc'}
grid.update_sort_settings(settings, src='request')
self.assertEqual(settings['sorters.length'], 1)
self.assertEqual(settings['sorters.1.key'], 'value')
self.assertEqual(settings['sorters.1.dir'], 'desc')
def test_update_page_settings(self): def test_update_page_settings(self):
# nothing happens if not paginated
grid = self.make_grid(key='foo') grid = self.make_grid(key='foo')
settings = {'pagesize': 20, 'page': 1}
self.request.session['grid.foo.pagesize'] = 10
self.request.session['grid.foo.page'] = 2
grid.update_page_settings(settings)
self.assertEqual(settings['pagesize'], 20)
self.assertEqual(settings['page'], 1)
# nb. now use a paginated grid
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
# settings are updated from session # settings are updated from session
settings = {'pagesize': 20, 'page': 1} settings = {'pagesize': 20, 'page': 1}
@ -225,52 +424,370 @@ class TestGrid(WebTestCase):
self.assertEqual(settings['page'], 4) self.assertEqual(settings['page'], 4)
def test_persist_settings(self): def test_persist_settings(self):
model = self.app.model
# nb. start out with paginated-only grid
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
# invalid dest
self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
# nb. no error if empty settings, but it saves null values # nb. no error if empty settings, but it saves null values
grid.persist_settings({}) grid.persist_settings({}, dest='session')
self.assertIsNone(self.request.session['grid.foo.page']) self.assertIsNone(self.request.session['grid.foo.page'])
# provided values are saved # provided values are saved
grid.persist_settings({'pagesize': 15, 'page': 3}) grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
self.assertEqual(self.request.session['grid.foo.page'], 3) self.assertEqual(self.request.session['grid.foo.page'], 3)
# nb. now switch to sortable-only grid
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True)
# no error if empty settings; does not save values
grid.persist_settings({}, dest='session')
self.assertNotIn('grid.settings.sorters.length', self.request.session)
# provided values are saved
grid.persist_settings({'sorters.length': 2,
'sorters.1.key': 'name',
'sorters.1.dir': 'desc',
'sorters.2.key': 'value',
'sorters.2.dir': 'asc'},
dest='session')
self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
# old values removed when new are saved
grid.persist_settings({'sorters.length': 1,
'sorters.1.key': 'name',
'sorters.1.dir': 'desc'},
dest='session')
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
##############################
# sorting methods
##############################
def test_make_backend_sorters(self):
model = self.app.model
# default is empty
grid = self.make_grid()
sorters = grid.make_backend_sorters()
self.assertEqual(sorters, {})
# makes sorters if model class
grid = self.make_grid(model_class=model.Setting)
sorters = grid.make_backend_sorters()
self.assertEqual(len(sorters), 2)
self.assertIn('name', sorters)
self.assertIn('value', sorters)
# does not replace supplied sorters
grid = self.make_grid(model_class=model.Setting)
mysorters = {'value': 42}
sorters = grid.make_backend_sorters(mysorters)
self.assertEqual(len(sorters), 2)
self.assertIn('name', sorters)
self.assertIn('value', sorters)
self.assertEqual(sorters['value'], 42)
self.assertEqual(mysorters['value'], 42)
def test_make_sorter(self):
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
# plain data
grid = self.make_grid(columns=['name', 'value'])
sorter = grid.make_sorter('name')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# model class, but still plain data
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('name')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# repeat previous test, w/ model property
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter(model.Setting.name)
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# sqlalchemy query
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('name')
sorted_query = sorter(sample_query, 'desc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
sorted_query = sorter(sample_query, 'asc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
# repeat previous test, w/ model property
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter(model.Setting.name)
sorted_query = sorter(sample_query, 'desc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
sorted_query = sorter(sample_query, 'asc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
# sortfunc for "invalid" column will fail when called; however
# it can work for manual sort w/ custom keyfunc
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('doesnotexist')
self.assertRaises(TypeError, sorter, sample_query, 'desc')
self.assertRaises(KeyError, sorter, sample_data, 'desc')
sorter = grid.make_sorter('doesnotexist', keyfunc=lambda obj: obj['name'])
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(len(sorted_data), 9)
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(len(sorted_data), 9)
# case folding is off by default
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('value')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
# results are different with case folding
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('value', foldcase=True)
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
def test_set_sorter(self):
model = self.app.model
# explicit sortfunc
grid = self.make_grid()
self.assertEqual(grid.sorters, {})
sortfunc = lambda data, direction: data
grid.set_sorter('foo', sortfunc)
self.assertIs(grid.sorters['foo'], sortfunc)
# auto from model property
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name', model.Setting.name)
self.assertTrue(callable(grid.sorters['name']))
# auto from column name
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name', 'name')
self.assertTrue(callable(grid.sorters['name']))
# auto from key
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name')
self.assertTrue(callable(grid.sorters['name']))
def test_remove_sorter(self):
model = self.app.model
# basics
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(len(grid.sorters), 2)
self.assertIn('name', grid.sorters)
self.assertIn('value', grid.sorters)
grid.remove_sorter('value')
self.assertNotIn('value', grid.sorters)
def test_set_sort_defaults(self):
model = self.app.model
# basics
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(grid.sort_defaults, [])
grid.set_sort_defaults('name', 'asc')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
def test_is_sortable(self):
model = self.app.model
# basics, frontend sorting
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=False)
self.assertTrue(grid.is_sortable('name'))
self.assertTrue(grid.is_sortable('value'))
grid.remove_sorter('value')
# nb. columns are always sortable for frontend, despite remove_sorter()
self.assertTrue(grid.is_sortable('value'))
# nb. when grid is not sortable, no column is either
grid.sortable = False
self.assertFalse(grid.is_sortable('name'))
# same test but with backend sorting
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=True)
self.assertTrue(grid.is_sortable('name'))
self.assertTrue(grid.is_sortable('value'))
grid.remove_sorter('value')
self.assertFalse(grid.is_sortable('value'))
# nb. when grid is not sortable, no column is either
grid.sortable = False
self.assertFalse(grid.is_sortable('name'))
############################## ##############################
# data methods # data methods
############################## ##############################
def test_get_visible_data(self): def test_get_visible_data(self):
data = [ model = self.app.model
{'foo': 1, 'bar': 1}, sample_data = [
{'foo': 2, 'bar': 2}, {'name': 'foo1', 'value': 'ONE'},
{'foo': 3, 'bar': 3}, {'name': 'foo2', 'value': 'two'},
{'foo': 4, 'bar': 4}, {'name': 'foo3', 'value': 'three'},
{'foo': 5, 'bar': 5}, {'name': 'foo4', 'value': 'four'},
{'foo': 6, 'bar': 6}, {'name': 'foo5', 'value': 'five'},
{'foo': 7, 'bar': 7}, {'name': 'foo6', 'value': 'six'},
{'foo': 8, 'bar': 8}, {'name': 'foo7', 'value': 'seven'},
{'foo': 9, 'bar': 9}, {'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
] ]
grid = self.make_grid(data=data, for setting in sample_data:
columns=['foo', 'bar'], self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
# data is sorted and paginated
grid = self.make_grid(model_class=model.Setting,
data=sample_query,
sortable=True, sort_on_backend=True,
sort_defaults=('name', 'desc'),
paginated=True, paginate_on_backend=True, paginated=True, paginate_on_backend=True,
pagesize=4, page=2) pagesize=4, page=2)
grid.load_settings()
visible = grid.get_visible_data() visible = grid.get_visible_data()
self.assertEqual(len(visible), 4) self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])
self.assertEqual(visible[0], {'foo': 5, 'bar': 5})
def test_sort_data(self):
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
grid = self.make_grid(model_class=model.Setting,
sortable=True, sort_on_backend=True,
sort_defaults=('name', 'desc'))
grid.load_settings()
# can sort a simple list of data
sorted_data = grid.sort_data(sample_data)
self.assertIsInstance(sorted_data, list)
self.assertEqual(len(sorted_data), 9)
self.assertEqual(sorted_data[0]['name'], 'foo9')
self.assertEqual(sorted_data[-1]['name'], 'foo1')
# can also sort a data query
sorted_query = grid.sort_data(sample_query)
self.assertIsInstance(sorted_query, orm.Query)
sorted_data = sorted_query.all()
self.assertEqual(len(sorted_data), 9)
self.assertEqual(sorted_data[0]['name'], 'foo9')
self.assertEqual(sorted_data[-1]['name'], 'foo1')
# cannot sort data if sorter missing in overrides
sorted_data = grid.sort_data(sample_data, sorters=[])
# nb. sorted data is in same order as original sample (not sorted)
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
# error if mult-column sort attempted
self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[
{'key': 'name', 'dir': 'desc'},
{'key': 'value', 'dir': 'asc'},
])
# cannot sort data if sortfunc is missing for column
grid.remove_sorter('name')
sorted_data = grid.sort_data(sample_data)
# nb. sorted data is in same order as original sample (not sorted)
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
def test_paginate_data(self): def test_paginate_data(self):
grid = self.make_grid() model = self.app.model
pager = grid.paginate_data([]) sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
# basic list pager
grid = self.make_grid(paginated=True, paginate_on_backend=True)
pager = grid.paginate_data(sample_data)
self.assertIsInstance(pager, Page) self.assertIsInstance(pager, Page)
# basic query pager
grid = self.make_grid(paginated=True, paginate_on_backend=True)
pager = grid.paginate_data(sample_query)
self.assertIsInstance(pager, SqlalchemyOrmPage)
# page is reset to 1 for empty data # page is reset to 1 for empty data
self.request.session['grid.foo.page'] = 2 self.request.session['grid.foo.page'] = 2
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
grid.load_settings() grid.load_settings()
self.assertEqual(grid.page, 2) self.assertEqual(grid.page, 2)
self.assertEqual(self.request.session['grid.foo.page'], 2) self.assertEqual(self.request.session['grid.foo.page'], 2)
pager = grid.paginate_data([]) pager = grid.paginate_data(sample_data)
self.assertEqual(pager.page, 1) self.assertEqual(pager.page, 1)
self.assertEqual(grid.page, 1) self.assertEqual(grid.page, 1)
self.assertEqual(self.request.session['grid.foo.page'], 1) self.assertEqual(self.request.session['grid.foo.page'], 1)
@ -322,7 +839,7 @@ class TestGrid(WebTestCase):
self.assertEqual(data, [{'foo': 'bar'}]) self.assertEqual(data, [{'foo': 'bar'}])
# if grid has actions, that list may be supplemented # if grid has actions, that list may be supplemented
grid.actions.append(base.GridAction(self.request, 'view', url='/blarg')) grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertIsNot(data, mydata) self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}]) self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
@ -362,7 +879,7 @@ class TestGridAction(TestCase):
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
def make_action(self, key, **kwargs): def make_action(self, key, **kwargs):
return base.GridAction(self.request, key, **kwargs) return mod.GridAction(self.request, key, **kwargs)
def test_render_icon(self): def test_render_icon(self):

View file

@ -4,6 +4,7 @@ import functools
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from sqlalchemy import orm
from pyramid import testing from pyramid import testing
from pyramid.response import Response from pyramid.response import Response
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
@ -518,11 +519,13 @@ class TestMasterView(WebTestCase):
data = view.get_grid_data(session=self.session) data = view.get_grid_data(session=self.session)
self.assertEqual(data, []) self.assertEqual(data, [])
# basic logic with Setting model # grid with model class will produce data query
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_class=model.Setting): model_class=model.Setting):
view = master.MasterView(self.request) view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session) query = view.get_grid_data(session=self.session)
self.assertIsInstance(query, orm.Query)
data = query.all()
self.assertEqual(len(data), 1) self.assertEqual(len(data), 1)
self.assertIs(data[0], setting) self.assertIs(data[0], setting)

View file

@ -2,9 +2,10 @@
from unittest.mock import patch from unittest.mock import patch
import colander
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings from wuttaweb.views import settings as mod
from tests.util import WebTestCase from tests.util import WebTestCase
@ -15,7 +16,7 @@ class TestAppInfoView(WebTestCase):
self.pyramid_config.include('wuttaweb.views.essential') self.pyramid_config.include('wuttaweb.views.essential')
def make_view(self): def make_view(self):
return settings.AppInfoView(self.request) return mod.AppInfoView(self.request)
def test_index(self): def test_index(self):
# sanity/coverage check # sanity/coverage check
@ -36,19 +37,21 @@ class TestAppInfoView(WebTestCase):
class TestSettingView(WebTestCase): class TestSettingView(WebTestCase):
def make_view(self): def make_view(self):
return settings.SettingView(self.request) return mod.SettingView(self.request)
def test_get_grid_data(self): def test_get_grid_data(self):
# empty data by default # empty data by default
view = self.make_view() view = self.make_view()
data = view.get_grid_data(session=self.session) query = view.get_grid_data(session=self.session)
data = query.all()
self.assertEqual(len(data), 0) self.assertEqual(len(data), 0)
# unless we save some settings # unless we save some settings
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit() self.session.commit()
data = view.get_grid_data(session=self.session) query = view.get_grid_data(session=self.session)
data = query.all()
self.assertEqual(len(data), 1) self.assertEqual(len(data), 1)
def test_configure_form(self): def test_configure_form(self):
@ -58,3 +61,23 @@ class TestSettingView(WebTestCase):
view.configure_form(form) view.configure_form(form)
self.assertIn('value', form.required_fields) self.assertIn('value', form.required_fields)
self.assertFalse(form.required_fields['value']) self.assertFalse(form.required_fields['value'])
def test_unique_name(self):
model = self.app.model
view = self.make_view()
setting = model.Setting(name='foo')
self.session.add(setting)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same name in data
node = colander.SchemaNode(colander.String(), name='name')
self.assertRaises(colander.Invalid, view.unique_name, node, 'foo')
# but not if name belongs to current setting
view.editing = True
self.request.matchdict = {'name': 'foo'}
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'foo'))