From ec36df4a341a1e8c7ba5821fa270fdb1125b1848 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Aug 2024 14:05:52 -0500 Subject: [PATCH] feat: move single-column grid sorting logic to wuttaweb --- tailbone/forms/core.py | 26 +-- tailbone/grids/core.py | 272 ++++++++++++++----------- tailbone/templates/grids/complete.mako | 63 +++--- tailbone/views/master.py | 28 +-- tailbone/views/wutta/people.py | 8 +- tests/grids/test_core.py | 245 +++++++++++++++++++++- tests/views/test_master.py | 33 ++- 7 files changed, 475 insertions(+), 200 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index eeae4537..704d3b54 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render 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.util import raw_datetime, render_markdown @@ -1418,30 +1418,6 @@ class Form(object): 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 def upload_widget(node, kw): request = kw['request'] diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 0b23fb78..cc1888fb 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -39,7 +39,8 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags 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 tailbone.db import Session from tailbone.util import raw_datetime @@ -48,23 +49,17 @@ from tailbone.util import raw_datetime 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): """ - 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/ @@ -206,10 +201,6 @@ class Grid(WuttaGrid): filters={}, use_byte_string_filters=False, searchable={}, - sortable=False, - sorters={}, - default_sortkey=None, - default_sortdir='asc', checkboxes=False, checked=None, check_handler=None, @@ -231,6 +222,20 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) 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'): warnings.warn("component param is deprecated for Grid(); " "please use vue_tagname param instead", @@ -284,11 +289,6 @@ class Grid(WuttaGrid): 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.checked = checked if self.checked is None: @@ -328,9 +328,7 @@ class Grid(WuttaGrid): @property def component(self): - """ - DEPRECATED - use :attr:`vue_tagname` instead. - """ + """ """ warnings.warn("Grid.component is deprecated; " "please use vue_tagname instead", DeprecationWarning, stacklevel=2) @@ -338,20 +336,66 @@ class Grid(WuttaGrid): @property def component_studly(self): - """ - DEPRECATED - use :attr:`vue_component` instead. - """ + """ """ warnings.warn("Grid.component_studly is deprecated; " "please use vue_component instead", DeprecationWarning, stacklevel=2) 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): """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) return self.paginated def set_pageable(self, value): """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) self.paginated = value pageable = property(get_pageable, set_pageable) @@ -405,18 +449,30 @@ class Grid(WuttaGrid): self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_sorter(key) + """ """ + + 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) + 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) - 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): if len(args) == 1 and args[0] is None: self.remove_filter(key) @@ -731,53 +787,12 @@ class Grid(WuttaGrid): if filtr.active: 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): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting a data set comprised of dicts, on the given key. - """ - if foldcase: - keyfunc = lambda v: v[key].lower() - else: - keyfunc = lambda v: v[key] - return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + """ """ + warnings.warn("Grid.make_simple_sorter() is deprecated; " + "please use Grid.make_sorter() instead", + DeprecationWarning, stacklevel=2) + return self.make_sorter(key, foldcase=foldcase) def get_pagesize_options(self, default=None): """ """ @@ -849,10 +864,17 @@ class Grid(WuttaGrid): # initial default settings settings = {} 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.1.key'] = self.default_sortkey - settings['sorters.1.dir'] = self.default_sortdir + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir else: settings['sorters.length'] = 0 if self.paginated: @@ -927,11 +949,12 @@ class Grid(WuttaGrid): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: + # and self.sort_on_backend: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ - 'field': settings[f'sorters.{i}.key'], - 'order': settings[f'sorters.{i}.dir'], + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], }) if self.paginated: self.pagesize = settings['pagesize'] @@ -1321,21 +1344,24 @@ class Grid(WuttaGrid): return data - def sort_data(self, data): - """ - Sort the given query according to current settings, and return the result. - """ - # bail if no sort settings - if not self.active_sorters: + def sort_data(self, data, sorters=None): + """ """ + if sorters is None: + sorters = self.active_sorters + if not sorters: return data - # TODO: is there a better way to check for SA sorting? - if self.model_class: + # sqlalchemy queries require special handling, in case of + # multi-column sorting + if isinstance(data, orm.Query): # collect actual column sorters for order_by clause - sorters = [] - for sorter in self.active_sorters: - sortkey = sorter['field'] + query_sorters = [] + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] + + # cannot sort unless we have a sorter callable sortfunc = self.sorters.get(sortkey) if not sortfunc: log.warning("unknown sorter: %s", sorter) @@ -1347,34 +1373,36 @@ class Grid(WuttaGrid): self.joined.add(sortkey) # add column/dir to collection - sortdir = sorter['order'] - sorters.append(getattr(sortfunc._column, sortdir)()) + query_sorters.append(getattr(sortfunc._column, sortdir)()) # apply sorting to query - if sorters: - data = data.order_by(*sorters) + if query_sorters: + data = data.order_by(*query_sorters) return data - else: - # not a SQLAlchemy grid, custom sorter + # manual sorting; only one column allowed + 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'] - sortdir = self.active_sorters[0]['order'] or 'asc' + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data + # 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: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) - # apply joins needed for this sorter - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - return sortfunc(data, sortdir) + # invoke the sorter + return sortfunc(data, sortdir) def paginate_data(self, data): """ @@ -1671,7 +1699,7 @@ class Grid(WuttaGrid): columns.append({ 'field': 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, }) return columns diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index d3981a16..5a005c2e 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -81,7 +81,11 @@ % 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 @sort="onSort" @sorting-priority-removed="sortingPriorityRemoved" @@ -93,8 +97,6 @@ ## https://github.com/buefy/buefy/issues/2584 :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 :sort-multiple-data="sortingPriority" @@ -272,7 +274,9 @@ % 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 ## displaying for simple default single-column sort. so to @@ -281,10 +285,7 @@ ## https://github.com/buefy/buefy/issues/2584 allowMultiSort: false, - ## nb. this contains all truly active sorters - backendSorters: ${json.dumps(grid.active_sorters)|n}, - - ## nb. whereas this will only contain multi-column sorters, + ## nb. this will only contain multi-column sorters, ## but will be *empty* for single-column sorting % if len(grid.active_sorters) > 1: sortingPriority: ${json.dumps(grid.active_sorters)|n}, @@ -474,17 +475,18 @@ }, getBasicParams() { - let params = {} - % if getattr(grid, 'sortable', False): - for (let i = 1; i <= this.backendSorters.length; i++) { - params['sort'+i+'key'] = this.backendSorters[i-1].field - params['sort'+i+'dir'] = this.backendSorters[i-1].order + const params = { + % if grid.paginated and grid.paginate_on_backend: + pagesize: this.perPage, + page: this.currentPage, + % endif + } + % if grid.sortable and grid.sort_on_backend: + for (let i = 1; i <= this.sorters.length; i++) { + params['sort'+i+'key'] = this.sorters[i-1].key + params['sort'+i+'dir'] = this.sorters[i-1].dir } % endif - % if grid.paginated: - params.pagesize = this.perPage - params.page = this.currentPage - % endif return params }, @@ -526,15 +528,15 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { 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 % if grid.paginated and grid.paginate_on_backend: this.pagerStats = response.data.pager_stats % endif - this.rowStatusMap = response.data.data.row_status_map + this.rowStatusMap = response.data.row_status_map || {} this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) + this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) if (success) { success() } @@ -597,26 +599,26 @@ onSort(field, order, event) { - // nb. buefy passes field name, oruga passes object - if (field.field) { + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: field = field.field - } + % endif if (event.ctrlKey) { // 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) { - sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc' } else { - this.backendSorters.push({field, order}) + this.sorters.push({key: field, dir: order}) } - this.sortingPriority = this.backendSorters + this.sortingPriority = this.sorters } else { // sort by single column only - this.backendSorters = [{field, order}] + this.sorters = [{key: field, dir: order}] this.sortingPriority = [] } @@ -629,12 +631,11 @@ sortingPriorityRemoved(field) { // prune field from active sorters - this.backendSorters = this.backendSorters.filter( - (sorter) => sorter.field !== field) + this.sorters = this.sorters.filter(s => s.key !== field) // nb. must keep active sorter list "as-is" even if // there is only one sorter; buefy seems to expect it - this.sortingPriority = this.backendSorters + this.sortingPriority = this.sorters this.loadAsyncData() }, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1fa0ae40..53f46020 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -345,8 +345,8 @@ class MasterView(View): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested - if self.request.params.get('partial'): - context = {'data': grid.get_table_data()} + if self.request.GET.get('partial'): + context = grid.get_table_data() if grid.paginated and grid.paginate_on_backend: context['pager_stats'] = grid.get_vue_pager_stats() 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 type of CRUD view is in effect, etc. """ + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model 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)\ .first() if info and info.help_url: @@ -2587,11 +2588,12 @@ class MasterView(View): """ 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 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)\ .first() if info and info.markdown_text: @@ -2608,6 +2610,8 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2625,13 +2629,12 @@ class MasterView(View): if not form.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)\ .first() if not info: info = model.TailbonePageHelp(route_prefix=route_prefix) - Session.add(info) + session.add(info) info.help_url = form.validated['help_url'] info.markdown_text = form.validated['markdown_text'] @@ -2641,6 +2644,8 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2657,15 +2662,14 @@ class MasterView(View): if not form.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.field_name == form.validated['field_name'])\ .first() if not info: info = model.TailboneFieldInfo(route_prefix=route_prefix, field_name=form.validated['field_name']) - Session.add(info) + session.add(info) info.markdown_text = form.validated['markdown_text'] return {'ok': True} diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index c10020ea..968eaf3d 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -44,6 +44,7 @@ class PersonView(wutta.PersonView): """ model_class = Person Session = Session + sort_defaults = 'display_name' labels = { 'display_name': "Full Name", @@ -73,13 +74,6 @@ class PersonView(wutta.PersonView): # 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): """ """ super().configure_grid(g) diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 7cba917a..9f9b816f 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -1,6 +1,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 tests.util import WebTestCase @@ -27,6 +29,16 @@ class TestGrid(WebTestCase): grid = self.make_grid(component='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 grid = self.make_grid() self.assertFalse(grid.paginated) @@ -159,6 +171,27 @@ class TestGrid(WebTestCase): grid.set_action_urls(setting, setting, 0) 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): grid = self.make_grid() self.assertFalse(grid.paginated) @@ -219,6 +252,212 @@ class TestGrid(WebTestCase): size = grid.get_pagesize() 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): model = self.app.model @@ -249,11 +488,13 @@ class TestGrid(WebTestCase): model = self.app.model # 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() self.assertEqual(len(columns), 2) self.assertEqual(columns[0]['field'], 'name') + self.assertTrue(columns[0]['sortable']) self.assertEqual(columns[1]['field'], 'value') + self.assertTrue(columns[1]['sortable']) def test_get_vue_data(self): model = self.app.model diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 572875a0..0e459e7d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,6 +1,6 @@ # -*- coding: utf-8; -*- -from unittest.mock import patch +from unittest.mock import patch, MagicMock from tailbone.views import master as mod from wuttaweb.grids import GridAction @@ -33,3 +33,34 @@ class TestMasterView(WebTestCase): view = self.make_view() action = view.make_action('view') 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')