feat: move single-column grid sorting logic to wuttaweb

This commit is contained in:
Lance Edgar 2024-08-18 14:05:52 -05:00
parent c95e42bf82
commit ec36df4a34
7 changed files with 475 additions and 200 deletions

View file

@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from wuttaweb.util import get_form_data, make_json_safe from wuttaweb.util import FieldList, get_form_data, make_json_safe
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import raw_datetime, render_markdown from tailbone.util import raw_datetime, render_markdown
@ -1418,30 +1418,6 @@ class Form(object):
return False return False
class FieldList(list):
"""
Convenience wrapper for a form's field list.
"""
def insert_before(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
@colander.deferred @colander.deferred
def upload_widget(node, kw): def upload_widget(node, kw):
request = kw['request'] request = kw['request']

View file

@ -39,7 +39,8 @@ from pyramid.renderers import render
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from paginate_sqlalchemy import SqlalchemyOrmPage from paginate_sqlalchemy import SqlalchemyOrmPage
from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
from wuttaweb.util import FieldList
from . import filters as gridfilters from . import filters as gridfilters
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import raw_datetime from tailbone.util import raw_datetime
@ -48,23 +49,17 @@ from tailbone.util import raw_datetime
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class FieldList(list):
"""
Convenience wrapper for a field list.
"""
def insert_before(self, field, newfield):
i = self.index(field)
self.insert(i, newfield)
def insert_after(self, field, newfield):
i = self.index(field)
self.insert(i + 1, newfield)
class Grid(WuttaGrid): class Grid(WuttaGrid):
""" """
Core grid class. In sore need of documentation. Base class for all grids.
This is now a subclass of
:class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
customizations which have traditionally been part of Tailbone.
Some of these customizations are still undocumented. Some will
eventually be moved to the upstream/parent class, and possibly
some will be removed outright. What docs we have, are shown here.
.. _Buefy docs: https://buefy.org/documentation/table/ .. _Buefy docs: https://buefy.org/documentation/table/
@ -206,10 +201,6 @@ class Grid(WuttaGrid):
filters={}, filters={},
use_byte_string_filters=False, use_byte_string_filters=False,
searchable={}, searchable={},
sortable=False,
sorters={},
default_sortkey=None,
default_sortdir='asc',
checkboxes=False, checkboxes=False,
checked=None, checked=None,
check_handler=None, check_handler=None,
@ -231,6 +222,20 @@ class Grid(WuttaGrid):
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('vue_tagname', kwargs.pop('component')) kwargs.setdefault('vue_tagname', kwargs.pop('component'))
if kwargs.get('default_sortkey'):
warnings.warn("default_sortkey param is deprecated for Grid(); "
"please use sort_defaults param instead",
DeprecationWarning, stacklevel=2)
if kwargs.get('default_sortdir'):
warnings.warn("default_sortdir param is deprecated for Grid(); "
"please use sort_defaults param instead",
DeprecationWarning, stacklevel=2)
if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'):
sortkey = kwargs.pop('default_sortkey', None)
sortdir = kwargs.pop('default_sortdir', 'asc')
if sortkey:
kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
if kwargs.get('pageable'): if kwargs.get('pageable'):
warnings.warn("component param is deprecated for Grid(); " warnings.warn("component param is deprecated for Grid(); "
"please use vue_tagname param instead", "please use vue_tagname param instead",
@ -284,11 +289,6 @@ class Grid(WuttaGrid):
self.searchable = searchable or {} self.searchable = searchable or {}
self.sortable = sortable
self.sorters = self.make_sorters(sorters)
self.default_sortkey = default_sortkey
self.default_sortdir = default_sortdir
self.checkboxes = checkboxes self.checkboxes = checkboxes
self.checked = checked self.checked = checked
if self.checked is None: if self.checked is None:
@ -328,9 +328,7 @@ class Grid(WuttaGrid):
@property @property
def component(self): def component(self):
""" """ """
DEPRECATED - use :attr:`vue_tagname` instead.
"""
warnings.warn("Grid.component is deprecated; " warnings.warn("Grid.component is deprecated; "
"please use vue_tagname instead", "please use vue_tagname instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
@ -338,20 +336,66 @@ class Grid(WuttaGrid):
@property @property
def component_studly(self): def component_studly(self):
""" """ """
DEPRECATED - use :attr:`vue_component` instead.
"""
warnings.warn("Grid.component_studly is deprecated; " warnings.warn("Grid.component_studly is deprecated; "
"please use vue_component instead", "please use vue_component instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
return self.vue_component return self.vue_component
def get_default_sortkey(self):
""" """
warnings.warn("Grid.default_sortkey is deprecated; "
"please use Grid.sort_defaults instead",
DeprecationWarning, stacklevel=2)
if self.sort_defaults:
return self.sort_defaults[0].sortkey
def set_default_sortkey(self, value):
""" """
warnings.warn("Grid.default_sortkey is deprecated; "
"please use Grid.sort_defaults instead",
DeprecationWarning, stacklevel=2)
if self.sort_defaults:
info = self.sort_defaults[0]
self.sort_defaults[0] = SortInfo(value, info.sortdir)
else:
self.sort_defaults = [SortInfo(value, 'asc')]
default_sortkey = property(get_default_sortkey, set_default_sortkey)
def get_default_sortdir(self):
""" """
warnings.warn("Grid.default_sortdir is deprecated; "
"please use Grid.sort_defaults instead",
DeprecationWarning, stacklevel=2)
if self.sort_defaults:
return self.sort_defaults[0].sortdir
def set_default_sortdir(self, value):
""" """
warnings.warn("Grid.default_sortdir is deprecated; "
"please use Grid.sort_defaults instead",
DeprecationWarning, stacklevel=2)
if self.sort_defaults:
info = self.sort_defaults[0]
self.sort_defaults[0] = SortInfo(info.sortkey, value)
else:
raise ValueError("cannot set default_sortdir without default_sortkey")
default_sortdir = property(get_default_sortdir, set_default_sortdir)
def get_pageable(self): def get_pageable(self):
""" """ """ """
warnings.warn("Grid.pageable is deprecated; "
"please use Grid.paginated instead",
DeprecationWarning, stacklevel=2)
return self.paginated return self.paginated
def set_pageable(self, value): def set_pageable(self, value):
""" """ """ """
warnings.warn("Grid.pageable is deprecated; "
"please use Grid.paginated instead",
DeprecationWarning, stacklevel=2)
self.paginated = value self.paginated = value
pageable = property(get_pageable, set_pageable) pageable = property(get_pageable, set_pageable)
@ -405,18 +449,30 @@ class Grid(WuttaGrid):
self.joiners[key] = joiner self.joiners[key] = joiner
def set_sorter(self, key, *args, **kwargs): def set_sorter(self, key, *args, **kwargs):
if len(args) == 1 and args[0] is None: """ """
if len(args) == 1:
if kwargs:
warnings.warn("kwargs are ignored for Grid.set_sorter(); "
"please refactor your code accordingly",
DeprecationWarning, stacklevel=2)
if args[0] is None:
warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
"please use Grid.remove_sorter() instead",
DeprecationWarning, stacklevel=2)
self.remove_sorter(key) self.remove_sorter(key)
else: else:
super().set_sorter(key, args[0])
elif len(args) == 0:
super().set_sorter(key)
else:
warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
"please refactor your code accordingly",
DeprecationWarning, stacklevel=2)
self.sorters[key] = self.make_sorter(*args, **kwargs) self.sorters[key] = self.make_sorter(*args, **kwargs)
def remove_sorter(self, key):
self.sorters.pop(key, None)
def set_sort_defaults(self, sortkey, sortdir='asc'):
self.default_sortkey = sortkey
self.default_sortdir = sortdir
def set_filter(self, key, *args, **kwargs): def set_filter(self, key, *args, **kwargs):
if len(args) == 1 and args[0] is None: if len(args) == 1 and args[0] is None:
self.remove_filter(key) self.remove_filter(key)
@ -731,53 +787,12 @@ class Grid(WuttaGrid):
if filtr.active: if filtr.active:
yield filtr yield filtr
def make_sorters(self, sorters=None):
"""
Returns an initial set of sorters which will be available to the grid.
The grid itself may or may not provide some default sorters, and the
``sorters`` kwarg may contain additions and/or overrides.
"""
sorters, updates = {}, sorters
if self.model_class:
mapper = orm.class_mapper(self.model_class)
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
sorters[prop.key] = self.make_sorter(prop)
if updates:
sorters.update(updates)
return sorters
def make_sorter(self, model_property):
"""
Returns a function suitable for a sort map callable, with typical logic
built in for sorting applied to ``field``.
"""
class_ = getattr(model_property, 'class_', self.model_class)
column = getattr(class_, model_property.key)
def sorter(query, direction):
# TODO: this seems hacky..normally we expect a true query
# of course, but in some cases it may be a list instead.
# if so then we can't actually sort
if isinstance(query, list):
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): def make_simple_sorter(self, key, foldcase=False):
""" """ """
Returns a function suitable for a sort map callable, with typical logic warnings.warn("Grid.make_simple_sorter() is deprecated; "
built in for sorting a data set comprised of dicts, on the given key. "please use Grid.make_sorter() instead",
""" DeprecationWarning, stacklevel=2)
if foldcase: return self.make_sorter(key, foldcase=foldcase)
keyfunc = lambda v: v[key].lower()
else:
keyfunc = lambda v: v[key]
return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
def get_pagesize_options(self, default=None): def get_pagesize_options(self, default=None):
""" """ """ """
@ -849,10 +864,17 @@ class Grid(WuttaGrid):
# initial default settings # initial default settings
settings = {} settings = {}
if self.sortable: if self.sortable:
if self.default_sortkey: 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.length'] = 1
settings['sorters.1.key'] = self.default_sortkey settings['sorters.1.key'] = sortinfo.sortkey
settings['sorters.1.dir'] = self.default_sortdir settings['sorters.1.dir'] = sortinfo.sortdir
else: else:
settings['sorters.length'] = 0 settings['sorters.length'] = 0
if self.paginated: if self.paginated:
@ -927,11 +949,12 @@ class Grid(WuttaGrid):
filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
filtr.value = settings['filter.{}.value'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)]
if self.sortable: if self.sortable:
# and self.sort_on_backend:
self.active_sorters = [] self.active_sorters = []
for i in range(1, settings['sorters.length'] + 1): for i in range(1, settings['sorters.length'] + 1):
self.active_sorters.append({ self.active_sorters.append({
'field': settings[f'sorters.{i}.key'], 'key': settings[f'sorters.{i}.key'],
'order': settings[f'sorters.{i}.dir'], 'dir': settings[f'sorters.{i}.dir'],
}) })
if self.paginated: if self.paginated:
self.pagesize = settings['pagesize'] self.pagesize = settings['pagesize']
@ -1321,21 +1344,24 @@ class Grid(WuttaGrid):
return data return data
def sort_data(self, data): def sort_data(self, data, sorters=None):
""" """ """
Sort the given query according to current settings, and return the result. if sorters is None:
""" sorters = self.active_sorters
# bail if no sort settings if not sorters:
if not self.active_sorters:
return data return data
# TODO: is there a better way to check for SA sorting? # sqlalchemy queries require special handling, in case of
if self.model_class: # multi-column sorting
if isinstance(data, orm.Query):
# collect actual column sorters for order_by clause # collect actual column sorters for order_by clause
sorters = [] query_sorters = []
for sorter in self.active_sorters: for sorter in sorters:
sortkey = sorter['field'] sortkey = sorter['key']
sortdir = sorter['dir']
# cannot sort unless we have a sorter callable
sortfunc = self.sorters.get(sortkey) sortfunc = self.sorters.get(sortkey)
if not sortfunc: if not sortfunc:
log.warning("unknown sorter: %s", sorter) log.warning("unknown sorter: %s", sorter)
@ -1347,33 +1373,35 @@ class Grid(WuttaGrid):
self.joined.add(sortkey) self.joined.add(sortkey)
# add column/dir to collection # add column/dir to collection
sortdir = sorter['order'] query_sorters.append(getattr(sortfunc._column, sortdir)())
sorters.append(getattr(sortfunc._column, sortdir)())
# apply sorting to query # apply sorting to query
if sorters: if query_sorters:
data = data.order_by(*sorters) data = data.order_by(*query_sorters)
return data return data
else: # manual sorting; only one column allowed
# not a SQLAlchemy grid, custom sorter if len(sorters) != 1:
raise NotImplementedError("mulit-column manual sorting not yet supported")
assert len(self.active_sorters) < 2 # our one and only active sorter
sorter = sorters[0]
sortkey = sorter['key']
sortdir = sorter['dir']
sortkey = self.active_sorters[0]['field'] # cannot sort unless we have a sorter callable
sortdir = self.active_sorters[0]['order'] or 'asc'
# Cannot sort unless we have a sort function.
sortfunc = self.sorters.get(sortkey) sortfunc = self.sorters.get(sortkey)
if not sortfunc: if not sortfunc:
return data return data
# apply joins needed for this sorter # apply joins needed for this sorter
# TODO: is this actually relevant for manual sort?
if sortkey in self.joiners and sortkey not in self.joined: if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data) data = self.joiners[sortkey](data)
self.joined.add(sortkey) self.joined.add(sortkey)
# invoke the sorter
return sortfunc(data, sortdir) return sortfunc(data, sortdir)
def paginate_data(self, data): def paginate_data(self, data):
@ -1671,7 +1699,7 @@ class Grid(WuttaGrid):
columns.append({ columns.append({
'field': name, 'field': name,
'label': self.get_label(name), 'label': self.get_label(name),
'sortable': self.sortable and name in self.sorters, 'sortable': self.is_sortable(name),
'visible': name not in self.invisible, 'visible': name not in self.invisible,
}) })
return columns return columns

View file

@ -81,7 +81,11 @@
% endif % endif
% endif % endif
% if getattr(grid, 'sortable', False): ## sorting
% if grid.sortable:
## nb. buefy only supports *one* default sorter
:default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
backend-sorting backend-sorting
@sort="onSort" @sort="onSort"
@sorting-priority-removed="sortingPriorityRemoved" @sorting-priority-removed="sortingPriorityRemoved"
@ -93,8 +97,6 @@
## https://github.com/buefy/buefy/issues/2584 ## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort" :sort-multiple="allowMultiSort"
## nb. specify default sort only if single-column
:default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
## nb. otherwise there may be default multi-column sort ## nb. otherwise there may be default multi-column sort
:sort-multiple-data="sortingPriority" :sort-multiple-data="sortingPriority"
@ -272,7 +274,9 @@
% endif % endif
% endif % endif
% if getattr(grid, 'sortable', False): ## sorting
% if grid.sortable:
sorters: ${json.dumps(grid.active_sorters)|n},
## TODO: there is a bug (?) which prevents the arrow from ## TODO: there is a bug (?) which prevents the arrow from
## displaying for simple default single-column sort. so to ## displaying for simple default single-column sort. so to
@ -281,10 +285,7 @@
## https://github.com/buefy/buefy/issues/2584 ## https://github.com/buefy/buefy/issues/2584
allowMultiSort: false, allowMultiSort: false,
## nb. this contains all truly active sorters ## nb. this will only contain multi-column sorters,
backendSorters: ${json.dumps(grid.active_sorters)|n},
## nb. whereas this will only contain multi-column sorters,
## but will be *empty* for single-column sorting ## but will be *empty* for single-column sorting
% if len(grid.active_sorters) > 1: % if len(grid.active_sorters) > 1:
sortingPriority: ${json.dumps(grid.active_sorters)|n}, sortingPriority: ${json.dumps(grid.active_sorters)|n},
@ -474,16 +475,17 @@
}, },
getBasicParams() { getBasicParams() {
let params = {} const params = {
% if getattr(grid, 'sortable', False): % if grid.paginated and grid.paginate_on_backend:
for (let i = 1; i <= this.backendSorters.length; i++) { pagesize: this.perPage,
params['sort'+i+'key'] = this.backendSorters[i-1].field page: this.currentPage,
params['sort'+i+'dir'] = this.backendSorters[i-1].order
}
% endif % endif
% if grid.paginated: }
params.pagesize = this.perPage % if grid.sortable and grid.sort_on_backend:
params.page = this.currentPage 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 % endif
return params return params
}, },
@ -526,15 +528,15 @@
this.loading = true this.loading = true
this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
if (!response.data.error) { if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data.data ${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData this.data = ${grid.vue_component}CurrentData
% if grid.paginated and grid.paginate_on_backend: % if grid.paginated and grid.paginate_on_backend:
this.pagerStats = response.data.pager_stats this.pagerStats = response.data.pager_stats
% endif % endif
this.rowStatusMap = response.data.data.row_status_map this.rowStatusMap = response.data.row_status_map || {}
this.loading = false this.loading = false
this.savingDefaults = false this.savingDefaults = false
this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
if (success) { if (success) {
success() success()
} }
@ -597,26 +599,26 @@
onSort(field, order, event) { onSort(field, order, event) {
// nb. buefy passes field name, oruga passes object ## nb. buefy passes field name; oruga passes field object
if (field.field) { % if request.use_oruga:
field = field.field field = field.field
} % endif
if (event.ctrlKey) { if (event.ctrlKey) {
// engage or enhance multi-column sorting // engage or enhance multi-column sorting
let sorter = this.backendSorters.filter(i => i.field === field)[0] const sorter = this.sorters.filter(s => s.key === field)[0]
if (sorter) { if (sorter) {
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc'
} else { } else {
this.backendSorters.push({field, order}) this.sorters.push({key: field, dir: order})
} }
this.sortingPriority = this.backendSorters this.sortingPriority = this.sorters
} else { } else {
// sort by single column only // sort by single column only
this.backendSorters = [{field, order}] this.sorters = [{key: field, dir: order}]
this.sortingPriority = [] this.sortingPriority = []
} }
@ -629,12 +631,11 @@
sortingPriorityRemoved(field) { sortingPriorityRemoved(field) {
// prune field from active sorters // prune field from active sorters
this.backendSorters = this.backendSorters.filter( this.sorters = this.sorters.filter(s => s.key !== field)
(sorter) => sorter.field !== field)
// nb. must keep active sorter list "as-is" even if // nb. must keep active sorter list "as-is" even if
// there is only one sorter; buefy seems to expect it // there is only one sorter; buefy seems to expect it
this.sortingPriority = this.backendSorters this.sortingPriority = this.sorters
this.loadAsyncData() this.loadAsyncData()
}, },

View file

@ -345,8 +345,8 @@ class MasterView(View):
self.first_visible_grid_index = grid.pager.first_item self.first_visible_grid_index = grid.pager.first_item
# return grid data only, if partial page was requested # return grid data only, if partial page was requested
if self.request.params.get('partial'): if self.request.GET.get('partial'):
context = {'data': grid.get_table_data()} context = grid.get_table_data()
if grid.paginated and grid.paginate_on_backend: if grid.paginated and grid.paginate_on_backend:
context['pager_stats'] = grid.get_vue_pager_stats() context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context) return self.json_response(context)
@ -2565,11 +2565,12 @@ class MasterView(View):
so if you like you can return a different help URL depending on which so if you like you can return a different help URL depending on which
type of CRUD view is in effect, etc. type of CRUD view is in effect, etc.
""" """
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model model = self.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
# nb. self.Session may differ, so use tailbone.db.Session info = session.query(model.TailbonePageHelp)\
info = Session.query(model.TailbonePageHelp)\
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
.first() .first()
if info and info.help_url: if info and info.help_url:
@ -2587,11 +2588,12 @@ class MasterView(View):
""" """
Return the markdown help text for current page, if defined. Return the markdown help text for current page, if defined.
""" """
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model model = self.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
# nb. self.Session may differ, so use tailbone.db.Session info = session.query(model.TailbonePageHelp)\
info = Session.query(model.TailbonePageHelp)\
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
.first() .first()
if info and info.markdown_text: if info and info.markdown_text:
@ -2608,6 +2610,8 @@ class MasterView(View):
if not self.can_edit_help(): if not self.can_edit_help():
raise self.forbidden() raise self.forbidden()
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model model = self.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
schema = colander.Schema() schema = colander.Schema()
@ -2625,13 +2629,12 @@ class MasterView(View):
if not form.validate(): if not form.validate():
return {'error': "Form did not validate"} return {'error': "Form did not validate"}
# nb. self.Session may differ, so use tailbone.db.Session info = session.query(model.TailbonePageHelp)\
info = Session.query(model.TailbonePageHelp)\
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
.first() .first()
if not info: if not info:
info = model.TailbonePageHelp(route_prefix=route_prefix) info = model.TailbonePageHelp(route_prefix=route_prefix)
Session.add(info) session.add(info)
info.help_url = form.validated['help_url'] info.help_url = form.validated['help_url']
info.markdown_text = form.validated['markdown_text'] info.markdown_text = form.validated['markdown_text']
@ -2641,6 +2644,8 @@ class MasterView(View):
if not self.can_edit_help(): if not self.can_edit_help():
raise self.forbidden() raise self.forbidden()
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model model = self.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
schema = colander.Schema() schema = colander.Schema()
@ -2657,15 +2662,14 @@ class MasterView(View):
if not form.validate(): if not form.validate():
return {'error': "Form did not validate"} return {'error': "Form did not validate"}
# nb. self.Session may differ, so use tailbone.db.Session info = session.query(model.TailboneFieldInfo)\
info = Session.query(model.TailboneFieldInfo)\
.filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\
.filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\
.first() .first()
if not info: if not info:
info = model.TailboneFieldInfo(route_prefix=route_prefix, info = model.TailboneFieldInfo(route_prefix=route_prefix,
field_name=form.validated['field_name']) field_name=form.validated['field_name'])
Session.add(info) session.add(info)
info.markdown_text = form.validated['markdown_text'] info.markdown_text = form.validated['markdown_text']
return {'ok': True} return {'ok': True}

View file

@ -44,6 +44,7 @@ class PersonView(wutta.PersonView):
""" """
model_class = Person model_class = Person
Session = Session Session = Session
sort_defaults = 'display_name'
labels = { labels = {
'display_name': "Full Name", 'display_name': "Full Name",
@ -73,13 +74,6 @@ class PersonView(wutta.PersonView):
# CRUD methods # CRUD methods
############################## ##############################
def get_query(self, session=None):
""" """
model = self.app.model
session = session or self.Session()
return session.query(model.Person)\
.order_by(model.Person.display_name)
def configure_grid(self, g): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from sqlalchemy import orm
from tailbone.grids import core as mod from tailbone.grids import core as mod
from tests.util import WebTestCase from tests.util import WebTestCase
@ -27,6 +29,16 @@ class TestGrid(WebTestCase):
grid = self.make_grid(component='blarg') grid = self.make_grid(component='blarg')
self.assertEqual(grid.vue_tagname, 'blarg') self.assertEqual(grid.vue_tagname, 'blarg')
# default_sortkey, default_sortdir
grid = self.make_grid()
self.assertEqual(grid.sort_defaults, [])
grid = self.make_grid(default_sortkey='name')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
grid = self.make_grid(default_sortdir='desc')
self.assertEqual(grid.sort_defaults, [])
grid = self.make_grid(default_sortkey='name', default_sortdir='desc')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# pageable # pageable
grid = self.make_grid() grid = self.make_grid()
self.assertFalse(grid.paginated) self.assertFalse(grid.paginated)
@ -159,6 +171,27 @@ class TestGrid(WebTestCase):
grid.set_action_urls(setting, setting, 0) grid.set_action_urls(setting, setting, 0)
self.assertEqual(setting['_action_url_view'], '/blarg') self.assertEqual(setting['_action_url_view'], '/blarg')
def test_default_sortkey(self):
grid = self.make_grid()
self.assertEqual(grid.sort_defaults, [])
self.assertIsNone(grid.default_sortkey)
grid.default_sortkey = 'name'
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
self.assertEqual(grid.default_sortkey, 'name')
grid.default_sortkey = 'value'
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
self.assertEqual(grid.default_sortkey, 'value')
def test_default_sortdir(self):
grid = self.make_grid()
self.assertEqual(grid.sort_defaults, [])
self.assertIsNone(grid.default_sortdir)
self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc')
grid.sort_defaults = [mod.SortInfo('name', 'asc')]
grid.default_sortdir = 'desc'
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
self.assertEqual(grid.default_sortdir, 'desc')
def test_pageable(self): def test_pageable(self):
grid = self.make_grid() grid = self.make_grid()
self.assertFalse(grid.paginated) self.assertFalse(grid.paginated)
@ -219,6 +252,212 @@ class TestGrid(WebTestCase):
size = grid.get_pagesize() size = grid.get_pagesize()
self.assertEqual(size, 15) self.assertEqual(size, 15)
def test_set_sorter(self):
model = self.app.model
grid = self.make_grid(model_class=model.Setting,
sortable=True, sort_on_backend=True)
# passing None will remove sorter
self.assertIn('name', grid.sorters)
grid.set_sorter('name', None)
self.assertNotIn('name', grid.sorters)
# can recreate sorter with just column name
grid.set_sorter('name')
self.assertIn('name', grid.sorters)
grid.remove_sorter('name')
self.assertNotIn('name', grid.sorters)
grid.set_sorter('name', 'name')
self.assertIn('name', grid.sorters)
# can recreate sorter with model property
grid.remove_sorter('name')
self.assertNotIn('name', grid.sorters)
grid.set_sorter('name', model.Setting.name)
self.assertIn('name', grid.sorters)
# extra kwargs are ignored
grid.remove_sorter('name')
self.assertNotIn('name', grid.sorters)
grid.set_sorter('name', model.Setting.name, foo='bar')
self.assertIn('name', grid.sorters)
# passing multiple args will invoke make_filter() directly
grid.remove_sorter('name')
self.assertNotIn('name', grid.sorters)
with patch.object(grid, 'make_sorter') as make_sorter:
make_sorter.return_value = 42
grid.set_sorter('name', 'foo', 'bar')
make_sorter.assert_called_once_with('foo', 'bar')
self.assertEqual(grid.sorters['name'], 42)
def test_make_simple_sorter(self):
model = self.app.model
grid = self.make_grid(model_class=model.Setting,
sortable=True, sort_on_backend=True)
# delegates to grid.make_sorter()
with patch.object(grid, 'make_sorter') as make_sorter:
make_sorter.return_value = 42
sorter = grid.make_simple_sorter('name', foldcase=True)
make_sorter.assert_called_once_with('name', foldcase=True)
self.assertEqual(sorter, 42)
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,
pagesize=20, page=1)
# settings are loaded, applied, saved
self.assertEqual(grid.page, 1)
self.assertNotIn('grid.foo.page', self.request.session)
self.request.GET = {'pagesize': '10', 'page': '2'}
grid.load_settings()
self.assertEqual(grid.page, 2)
self.assertEqual(self.request.session['grid.foo.page'], 2)
# can skip the saving step
self.request.GET = {'pagesize': '10', 'page': '3'}
grid.load_settings(store=False)
self.assertEqual(grid.page, 3)
self.assertEqual(self.request.session['grid.foo.page'], 2)
# no error for non-paginated grid
grid = self.make_grid(key='foo', paginated=False)
grid.load_settings()
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_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')
# cannot sort data if sortfunc is missing for column
grid.remove_sorter('name')
# nb. attempting multi-column sort, but only one sorter exists
self.assertEqual(list(grid.sorters), ['value'])
grid.active_sorters = [{'key': 'name', 'dir': 'asc'},
{'key': 'value', 'dir': 'asc'}]
with patch.object(sample_query, 'order_by') as order_by:
order_by.return_value = 42
sorted_query = grid.sort_data(sample_query)
order_by.assert_called_once()
self.assertEqual(len(order_by.call_args.args), 1)
self.assertEqual(sorted_query, 42)
def test_render_vue_tag(self): def test_render_vue_tag(self):
model = self.app.model model = self.app.model
@ -249,11 +488,13 @@ class TestGrid(WebTestCase):
model = self.app.model model = self.app.model
# sanity check # sanity check
grid = self.make_grid('settings', model_class=model.Setting) grid = self.make_grid('settings', model_class=model.Setting, sortable=True)
columns = grid.get_vue_columns() columns = grid.get_vue_columns()
self.assertEqual(len(columns), 2) self.assertEqual(len(columns), 2)
self.assertEqual(columns[0]['field'], 'name') self.assertEqual(columns[0]['field'], 'name')
self.assertTrue(columns[0]['sortable'])
self.assertEqual(columns[1]['field'], 'value') self.assertEqual(columns[1]['field'], 'value')
self.assertTrue(columns[1]['sortable'])
def test_get_vue_data(self): def test_get_vue_data(self):
model = self.app.model model = self.app.model

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest.mock import patch from unittest.mock import patch, MagicMock
from tailbone.views import master as mod from tailbone.views import master as mod
from wuttaweb.grids import GridAction from wuttaweb.grids import GridAction
@ -33,3 +33,34 @@ class TestMasterView(WebTestCase):
view = self.make_view() view = self.make_view()
action = view.make_action('view') action = view.make_action('view')
self.assertIsInstance(action, GridAction) self.assertIsInstance(action, GridAction)
def test_index(self):
self.pyramid_config.include('tailbone.views.common')
self.pyramid_config.include('tailbone.views.auth')
model = self.app.model
# mimic view for /settings
with patch.object(mod, 'Session', return_value=self.session):
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
Session=MagicMock(return_value=self.session),
get_index_url=MagicMock(return_value='/settings/'),
get_help_url=MagicMock(return_value=None)):
# basic
view = self.make_view()
response = view.index()
self.assertEqual(response.status_code, 200)
# then again with data, to include view action url
data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_data', return_value=data):
response = view.index()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_type, 'text/html')
# then once more as 'partial' - aka. data only
self.request.GET = {'partial': '1'}
response = view.index()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_type, 'application/json')