From 9751bf4c2ec8d62efb6a983230a3c221b3df4b41 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Aug 2024 20:15:23 -0500 Subject: [PATCH] feat: add initial filtering logic to grid class still missing the actual filters, subclass must provide those for now --- src/wuttaweb/grids/base.py | 353 ++++++++++++++++++++++++++++++++++++- tests/grids/test_base.py | 300 +++++++++++++++++++++++++++++-- 2 files changed, 633 insertions(+), 20 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 48117e0..ce4f8f8 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -288,6 +288,28 @@ class Grid: Set of columns declared as searchable for the Vue component. See also :meth:`set_searchable()` and :meth:`is_searchable()`. + + .. attribute:: filterable + + Boolean indicating whether the grid should show a "filters" + section where user can filter data in various ways. Default is + ``False``. + + .. attribute:: filters + + Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances + available for use with backend filtering. + + Only relevant if :attr:`filterable` is true. + + See also :meth:`set_filter()`. + + .. attribute:: joiners + + Dict of "joiner" functions for use with backend filtering and + sorting. + + See :meth:`set_joiner()` for more info. """ def __init__( @@ -313,6 +335,9 @@ class Grid: pagesize=None, page=1, searchable_columns=None, + filterable=False, + filters=None, + joiners=None, ): self.request = request self.vue_tagname = vue_tagname @@ -323,6 +348,7 @@ class Grid: self.renderers = renderers or {} self.actions = actions or [] self.linked_columns = linked_columns or [] + self.joiners = joiners or {} self.config = self.request.wutta_config self.app = self.config.get_app() @@ -354,6 +380,15 @@ class Grid: # searching self.searchable_columns = set(searchable_columns or []) + # filtering + self.filterable = filterable + if filters is not None: + self.filters = filters + elif self.filterable: + self.filters = self.make_backend_filters() + else: + self.filters = {} + def get_columns(self): """ Returns the official list of column names for the grid, or @@ -442,7 +477,7 @@ class Grid: if key in self.columns: self.columns.remove(key) - def set_label(self, key, label): + def set_label(self, key, label, column_only=False): """ Set/override the label for a column. @@ -450,11 +485,18 @@ class Grid: :param label: New label for the column header. + :param column_only: Boolean indicating whether the label + should be applied *only* to the column header (if + ``True``), vs. applying also to the filter (if ``False``). + See also :meth:`get_label()`. Label overrides are tracked via :attr:`labels`. """ self.labels[key] = label + if not column_only and key in self.filters: + self.filters[key].label = label + def get_label(self, key): """ Returns the label text for a given column. @@ -582,6 +624,63 @@ class Grid: """ self.actions.append(GridAction(self.request, key, **kwargs)) + ############################## + # joining methods + ############################## + + def set_joiner(self, key, joiner): + """ + Set/override the backend joiner for a column. + + A "joiner" is sometimes needed when a column with "related but + not primary" data is involved in a sort or filter operation. + + A sorter or filter may need to "join" other table(s) to get at + the appropriate data. But if a given column has both a sorter + and filter defined, and both are used at the same time, we + don't want the join to happen twice. + + Hence we track joiners separately, also keyed by column name + (as are sorters and filters). When a column's sorter **and/or** + filter is needed, the joiner will be invoked. + + :param key: Name of column. + + :param joiner: A joiner callable, as described below. + + A joiner callable must accept just one ``(data)`` arg and + return the "joined" data/query, for example:: + + model = app.model + grid = Grid(request, model_class=model.Person) + + def join_external_profile_value(query): + return query.join(model.ExternalProfile) + + def sort_external_profile(query, direction): + sortspec = getattr(model.ExternalProfile.description, direction) + return query.order_by(sortspec()) + + grid.set_joiner('external_profile', join_external_profile) + grid.set_sorter('external_profile', sort_external_profile) + + See also :meth:`remove_joiner()`. Backend joiners are tracked + via :attr:`joiners`. + """ + self.joiners[key] = joiner + + def remove_joiner(self, key): + """ + Remove the backend joiner for a column. + + Note that this removes the joiner *function*, so there is no + way to apply joins for this column unless another joiner is + later defined for it. + + See also :meth:`set_joiner()`. + """ + self.joiners.pop(key, None) + ############################## # sorting methods ############################## @@ -883,6 +982,120 @@ class Grid: return key in self.sorters return True + ############################## + # filtering methods + ############################## + + def make_backend_filters(self, filters=None): + """ + Make backend filters for all columns in the grid. + + This is called by the constructor, if :attr:`filterable` is + true. + + For each column in the grid, this checks the provided + ``filters`` and if the column is not yet in there, will call + :meth:`make_filter()` to add it. + + .. note:: + + This only works if grid has a :attr:`model_class`. If not, + this method just returns the initial filters (or empty + dict). + + :param filters: Optional dict of initial filters. Any + existing filters will be left intact, not replaced. + + :returns: Final dict of all filters. Includes any from the + initial ``filters`` param as well as any which were + created. + """ + filters = filters or {} + + if self.model_class: + for key in self.columns: + if key in filters: + continue + prop = getattr(self.model_class, key, None) + if (prop and hasattr(prop, 'property') + and isinstance(prop.property, orm.ColumnProperty)): + filters[prop.key] = self.make_filter(prop) + + return filters + + def make_filter(self, columninfo, **kwargs): + """ + Creates and returns a + :class:`~wuttaweb.grids.filters.GridFilter` instance suitable + for use as a backend filter on the given column. + + .. warning:: + + This method is not yet implemented; subclass *must* + override. + + Code usually does not need to call this directly. See also + :meth:`set_filter()`, which calls this method automatically. + + :param columninfo: Can be either a model property (see below), + or a column name. + + :returns: A :class:`~wuttaweb.grids.filters.GridFilter` + instance suitable for backend sorting. + """ + if isinstance(columninfo, str): + key = columninfo + else: + model_property = columninfo + key = model_property.key + + return GridFilter(self.request, key, **kwargs) + + def set_filter(self, key, filterinfo=None, **kwargs): + """ + Set/override the backend filter for a column. + + Only relevant if :attr:`filterable` is true. + + :param key: Name of column. + + :param filterinfo: Can be either a + :class:`~wuttweb.grids.filters.GridFilter` instance, or + else a model property (see below). + + If ``filterinfo`` is a ``GridFilter`` instance, it will be + used as-is for the backend filter. + + Otherwise :meth:`make_filter()` will be called to obtain the + backend filter. The ``filterinfo`` will be passed along to + that call; if it is empty then ``key`` will be used instead. + + See also :meth:`remove_filter()`. Backend filters are tracked + via :attr:`filters`. + """ + filtr = None + + if filterinfo and callable(filterinfo): + # filtr = filterinfo + raise NotImplementedError + else: + kwargs.setdefault('label', self.get_label(key)) + filtr = self.make_filter(filterinfo or key, **kwargs) + + self.filters[key] = filtr + + def remove_filter(self, key): + """ + Remove the backend filter for a column. + + This removes the filter *instance*, so there is no way to + filter by this column unless another filter is later defined + for it. + + See also :meth:`set_filter()`. + """ + self.filters.pop(key, None) + ############################## # paging methods ############################## @@ -973,6 +1186,11 @@ class Grid: # initial default settings settings = {} + if self.filterable: + for filtr in self.filters.values(): + settings[f'filter.{filtr.key}.active'] = filtr.default_active + settings[f'filter.{filtr.key}.verb'] = filtr.default_verb + settings[f'filter.{filtr.key}.value'] = filtr.default_value if self.sortable: if self.sort_defaults: # nb. as of writing neither Buefy nor Oruga support a @@ -987,14 +1205,35 @@ class Grid: settings['pagesize'] = self.pagesize settings['page'] = self.page + # TODO + # # If user has default settings on file, apply those first. + # if self.user_has_defaults(): + # self.apply_user_defaults(settings) + + # TODO + # # If request contains instruction to reset to default filters, then we + # # can skip the rest of the request/session checks. + # if self.request.GET.get('reset-to-default-filters') == 'true': + # pass + # update settings dict based on what we find in the request # and/or user session. always prioritize the former. - if self.request_has_settings('sort'): + if self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') + if self.request_has_settings('sort'): + self.update_sort_settings(settings, src='request') + else: + self.update_sort_settings(settings, src='session') + self.update_page_settings(settings) + + elif self.request_has_settings('sort'): + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='request') self.update_page_settings(settings) elif self.request_has_settings('page'): + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -1003,6 +1242,7 @@ class Grid: persist = False # but still should load whatever is in user session + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -1010,8 +1250,21 @@ class Grid: if persist: self.persist_settings(settings, dest='session') + # TODO + # # If request contained instruction to save current settings as defaults + # # for the current user, then do that. + # if self.request.GET.get('save-current-filters-as-defaults') == 'true': + # self.persist_settings(settings, dest='defaults') + # update ourself to reflect settings dict.. + # filtering + if self.filterable: + for filtr in self.filters.values(): + filtr.active = settings[f'filter.{filtr.key}.active'] + filtr.verb = settings[f'filter.{filtr.key}.verb'] + filtr.value = settings[f'filter.{filtr.key}.value'] + # sorting if self.sortable: # nb. doing this for frontend sorting also @@ -1036,11 +1289,18 @@ class Grid: def request_has_settings(self, typ): """ """ - if typ == 'sort': + if typ == 'filter' and self.filterable: + for filtr in self.filters.values(): + if filtr.key in self.request.GET: + return True + if 'filter' in self.request.GET: # user may be applying empty filters + return True + + elif typ == 'sort' and self.sortable and self.sort_on_backend: if 'sort1key' in self.request.GET: return True - elif typ == 'page': + elif typ == 'page' and self.paginated and self.paginate_on_backend: for key in ['pagesize', 'page']: if key in self.request.GET: return True @@ -1072,6 +1332,31 @@ class Grid: # okay then, default it is return default + def update_filter_settings(self, settings, src=None): + """ """ + if not self.filterable: + return + + for filtr in self.filters.values(): + prefix = f'filter.{filtr.key}' + + if src == 'request': + # consider filter active if query string contains a value for it + settings[f'{prefix}.active'] = filtr.key in self.request.GET + settings[f'{prefix}.verb'] = self.get_setting( + settings, f'{filtr.key}.verb', src='request', default='') + settings[f'{prefix}.value'] = self.get_setting( + settings, filtr.key, src='request', default='') + + elif src == 'session': + settings[f'{prefix}.active'] = self.get_setting( + settings, f'{prefix}.active', src='session', + normalize=lambda v: str(v).lower() == 'true', default=False) + settings[f'{prefix}.verb'] = self.get_setting( + settings, f'{prefix}.verb', src='session', default='') + settings[f'{prefix}.value'] = self.get_setting( + settings, f'{prefix}.value', src='session', default='') + def update_sort_settings(self, settings, src=None): """ """ if not (self.sortable and self.sort_on_backend): @@ -1138,8 +1423,18 @@ class Grid: skey = f'grid.{self.key}.{key}' self.request.session[skey] = value(key) + # filter settings + if self.filterable: + + # always save all filters, with status + for filtr in self.filters.values(): + persist(f'filter.{filtr.key}.active', + value=lambda k: 'true' if settings.get(k) else 'false') + persist(f'filter.{filtr.key}.verb') + persist(f'filter.{filtr.key}.value') + # sort settings - if self.sortable: + if self.sortable and self.sort_on_backend: # first must clear all sort settings from dest. this is # because number of sort settings will vary, so we delete @@ -1183,10 +1478,15 @@ class Grid: See also these methods which may be called by this one: + * :meth:`filter_data()` * :meth:`sort_data()` * :meth:`paginate_data()` """ data = self.data or [] + self.joined = set() + + if self.filterable: + data = self.filter_data(data) if self.sortable and self.sort_on_backend: data = self.sort_data(data) @@ -1197,6 +1497,21 @@ class Grid: return data + def filter_data(self, data, filters=None): + """ + Filter the given data and return the result. This is called + by :meth:`get_visible_data()`. + + :param filters: Optional list of filters to use. If not + specified, the grid's "active" filters are used. + + .. warning:: + + This method is not yet implemented; subclass *must* + override. + """ + raise NotImplementedError + def sort_data(self, data, sorters=None): """ Sort the given data and return the result. This is called by @@ -1227,6 +1542,11 @@ class Grid: if not sortfunc: return data + # join appropriate model if needed + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + # invoke the sorter data = sortfunc(data, sortdir) @@ -1687,3 +2007,26 @@ class GridAction: return self.url(obj, i) return self.url + + +# TODO: this needs plenty of work yet..and probably will move? +class GridFilter: + """ """ + + def __init__( + self, + request, + key, + default_active=False, + default_verb=None, + default_value=None, + **kwargs, + ): + self.request = request + self.key = key + + self.default_active = default_active + self.default_verb = default_verb + self.default_value = default_value + + self.__dict__.update(kwargs) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 3598727..3cef14e 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -1,7 +1,7 @@ # -*- coding: utf-8; -*- from unittest import TestCase -from unittest.mock import patch +from unittest.mock import patch, MagicMock from sqlalchemy import orm from paginate import Page @@ -86,6 +86,32 @@ class TestGrid(WebTestCase): sort_multiple=True) self.assertFalse(grid.sort_multiple) + def test_constructor_filtering(self): + model = self.app.model + + # defaults, not filterable + grid = self.make_grid() + self.assertFalse(grid.filterable) + self.assertEqual(grid.filters, {}) + + # defaults, filterable + grid = self.make_grid(filterable=True) + self.assertTrue(grid.filterable) + self.assertEqual(grid.filters, {}) + + # filters may be pre-populated + with patch.object(mod.Grid, 'make_filter', return_value=42): + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(len(grid.filters), 2) + self.assertIn('name', grid.filters) + self.assertIn('value', grid.filters) + + # can specify filters + grid = self.make_grid(model_class=model.Setting, filterable=True, + filters={'name': 42}) + self.assertTrue(grid.filterable) + self.assertEqual(grid.filters, {'name': 42}) + def test_vue_tagname(self): grid = self.make_grid() self.assertEqual(grid.vue_tagname, 'wutta-grid') @@ -125,17 +151,28 @@ class TestGrid(WebTestCase): self.assertEqual(grid.columns, ['one', 'four']) def test_set_label(self): - grid = self.make_grid(columns=['foo', 'bar']) + model = self.app.model + with patch.object(mod.Grid, 'make_filter'): + # nb. filters are MagicMock instances + grid = self.make_grid(model_class=model.Setting, filterable=True) self.assertEqual(grid.labels, {}) # basic - grid.set_label('foo', "Foo Fighters") - self.assertEqual(grid.labels['foo'], "Foo Fighters") + grid.set_label('name', "NAME COL") + self.assertEqual(grid.labels['name'], "NAME COL") # can replace label - grid.set_label('foo', "Different") - self.assertEqual(grid.labels['foo'], "Different") - self.assertEqual(grid.get_label('foo'), "Different") + grid.set_label('name', "Different") + self.assertEqual(grid.labels['name'], "Different") + self.assertEqual(grid.get_label('name'), "Different") + + # can update only column, not filter + self.assertEqual(grid.labels, {'name': "Different"}) + self.assertIn('name', grid.filters) + self.assertEqual(grid.filters['name'].label, "Different") + grid.set_label('name', "COLUMN ONLY", column_only=True) + self.assertEqual(grid.get_label('name'), "COLUMN ONLY") + self.assertEqual(grid.filters['name'].label, "Different") def test_get_label(self): grid = self.make_grid(columns=['foo', 'bar']) @@ -342,20 +379,72 @@ class TestGrid(WebTestCase): grid.load_settings() self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + # filter settings are loaded, applied, saved + grid = self.make_grid(key='settings', model_class=model.Setting, + filterable=True) + self.assertEqual(len(grid.filters), 2) + self.assertFalse(hasattr(grid.filters['name'], 'active')) + self.assertFalse(hasattr(grid.filters['value'], 'active')) + self.assertNotIn('grid.settings.filter.name.active', self.request.session) + self.assertNotIn('grid.settings.filter.value.active', self.request.session) + self.request.GET = {'name': 'john', 'name.verb': 'contains'} + grid.load_settings() + self.assertTrue(grid.filters['name'].active) + self.assertEqual(grid.filters['name'].verb, 'contains') + self.assertEqual(grid.filters['name'].value, 'john') + self.assertTrue(self.request.session['grid.settings.filter.name.active']) + self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains') + self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john') + + # filter + sort settings are loaded, applied, saved + self.request.session.invalidate() + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, filterable=True) + self.assertEqual(len(grid.filters), 2) + self.assertFalse(hasattr(grid.filters['name'], 'active')) + self.assertFalse(hasattr(grid.filters['value'], 'active')) + self.assertNotIn('grid.settings.filter.name.active', self.request.session) + self.assertNotIn('grid.settings.filter.value.active', self.request.session) + self.assertNotIn('grid.settings.sorters.length', self.request.session) + self.request.GET = {'name': 'john', 'name.verb': 'contains', + 'sort1key': 'name', 'sort1dir': 'asc'} + grid.load_settings() + self.assertTrue(grid.filters['name'].active) + self.assertEqual(grid.filters['name'].verb, 'contains') + self.assertEqual(grid.filters['name'].value, 'john') + self.assertTrue(self.request.session['grid.settings.filter.name.active']) + self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains') + self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john') + 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'], 'asc') + def test_request_has_settings(self): - grid = self.make_grid(key='foo') + model = self.app.model + grid = self.make_grid(key='settings', model_class=model.Setting) # paging self.assertFalse(grid.request_has_settings('page')) - with patch.object(self.request, 'GET', new={'pagesize': '20'}): - self.assertTrue(grid.request_has_settings('page')) - with patch.object(self.request, 'GET', new={'page': '1'}): - self.assertTrue(grid.request_has_settings('page')) + with patch.object(grid, 'paginated', new=True): + with patch.object(self.request, 'GET', new={'pagesize': '20'}): + self.assertTrue(grid.request_has_settings('page')) + with patch.object(self.request, 'GET', new={'page': '1'}): + 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')) + with patch.object(grid, 'sortable', new=True): + with patch.object(self.request, 'GET', new={'sort1key': 'name'}): + self.assertTrue(grid.request_has_settings('sort')) + + # filtering + grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) + self.assertFalse(grid.request_has_settings('filter')) + with patch.object(grid, 'filterable', new=True): + with patch.object(self.request, 'GET', new={'name': 'john', 'name.verb': 'contains'}): + self.assertTrue(grid.request_has_settings('filter')) + with patch.object(self.request, 'GET', new={'filter': '1'}): + self.assertTrue(grid.request_has_settings('filter')) def test_get_setting(self): grid = self.make_grid(key='foo') @@ -400,6 +489,40 @@ class TestGrid(WebTestCase): value = grid.get_setting(settings, 'pagesize', src='session', normalize=int) self.assertEqual(value, 35) + def test_update_filter_settings(self): + model = self.app.model + + # nothing happens if not filterable + grid = self.make_grid(key='settings', model_class=model.Setting) + settings = {} + self.request.session['grid.settings.filter.name.active'] = True + self.request.session['grid.settings.filter.name.verb'] = 'contains' + self.request.session['grid.settings.filter.name.value'] = 'john' + grid.update_filter_settings(settings, src='session') + self.assertEqual(settings, {}) + + # nb. now use a filterable grid + grid = self.make_grid(key='settings', model_class=model.Setting, + filterable=True) + + # settings are updated from session + settings = {} + self.request.session['grid.settings.filter.name.active'] = True + self.request.session['grid.settings.filter.name.verb'] = 'contains' + self.request.session['grid.settings.filter.name.value'] = 'john' + grid.update_filter_settings(settings, src='session') + self.assertTrue(settings['filter.name.active']) + self.assertEqual(settings['filter.name.verb'], 'contains') + self.assertEqual(settings['filter.name.value'], 'john') + + # settings are updated from request + self.request.GET = {'value': 'sally', 'value.verb': 'contains'} + grid.update_filter_settings(settings, src='request') + self.assertFalse(settings['filter.name.active']) + self.assertTrue(settings['filter.value.active']) + self.assertEqual(settings['filter.value.verb'], 'contains') + self.assertEqual(settings['filter.value.value'], 'sally') + def test_update_sort_settings(self): model = self.app.model @@ -510,6 +633,25 @@ class TestGrid(WebTestCase): self.assertNotIn('grid.settings.sorters.2.key', self.request.session) self.assertNotIn('grid.settings.sorters.2.dir', self.request.session) + # nb. now switch to filterable-only grid + grid = self.make_grid(key='settings', model_class=model.Setting, + filterable=True) + self.assertIn('name', grid.filters) + self.assertEqual(grid.filters['name'].key, 'name') + + # no error if empty settings; does not save values + grid.persist_settings({}, dest='session') + self.assertNotIn('grid.settings.filters.name', self.request.session) + + # provided values are saved + grid.persist_settings({'filter.name.active': True, + 'filter.name.verb': 'contains', + 'filter.name.value': 'john'}, + dest='session') + self.assertTrue(self.request.session['grid.settings.filter.name.active']) + self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains') + self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john') + ############################## # sorting methods ############################## @@ -629,6 +771,23 @@ class TestGrid(WebTestCase): sorted_data = sorter(sample_data, 'asc') self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'}) + def test_set_joiner(self): + + # basic + grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True) + self.assertEqual(grid.joiners, {}) + grid.set_joiner('foo', 42) + self.assertEqual(grid.joiners, {'foo': 42}) + + def test_remove_joiner(self): + + # basic + grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True, + joiners={'foo': 42}) + self.assertEqual(grid.joiners, {'foo': 42}) + grid.remove_joiner('foo') + self.assertEqual(grid.joiners, {}) + def test_set_sorter(self): model = self.app.model @@ -726,6 +885,89 @@ class TestGrid(WebTestCase): grid.sortable = False self.assertFalse(grid.is_sortable('name')) + def test_make_backend_filters(self): + model = self.app.model + + # default is empty + grid = self.make_grid() + filters = grid.make_backend_filters() + self.assertEqual(filters, {}) + + # makes filters if model class + with patch.object(mod.Grid, 'make_filter'): + # nb. filters are MagicMock instances + grid = self.make_grid(model_class=model.Setting) + filters = grid.make_backend_filters() + self.assertEqual(len(filters), 2) + self.assertIn('name', filters) + self.assertIn('value', filters) + + # does not replace supplied filters + myfilters = {'value': 42} + with patch.object(mod.Grid, 'make_filter'): + # nb. filters are MagicMock instances + grid = self.make_grid(model_class=model.Setting) + filters = grid.make_backend_filters(myfilters) + self.assertEqual(len(filters), 2) + self.assertIn('name', filters) + self.assertIn('value', filters) + self.assertEqual(filters['value'], 42) + self.assertEqual(myfilters['value'], 42) + + def test_make_filter(self): + model = self.app.model + + # basic + grid = self.make_grid(model_class=model.Setting) + filtr = grid.make_filter('name') + self.assertIsInstance(filtr, mod.GridFilter) + + # property + grid = self.make_grid(model_class=model.Setting) + filtr = grid.make_filter(model.Setting.name) + self.assertIsInstance(filtr, mod.GridFilter) + + def test_set_filter(self): + model = self.app.model + + with patch.object(mod.Grid, 'make_filter', return_value=42): + + # auto from model property + grid = self.make_grid(model_class=model.Setting) + self.assertEqual(grid.filters, {}) + grid.set_filter('name', model.Setting.name) + self.assertIn('name', grid.filters) + + # auto from column name + grid = self.make_grid(model_class=model.Setting) + self.assertEqual(grid.filters, {}) + grid.set_filter('name', 'name') + self.assertIn('name', grid.filters) + + # auto from key + grid = self.make_grid(model_class=model.Setting) + self.assertEqual(grid.filters, {}) + grid.set_filter('name') + self.assertIn('name', grid.filters) + + # explicit is not yet implemented + grid = self.make_grid(model_class=model.Setting) + self.assertEqual(grid.filters, {}) + self.assertRaises(NotImplementedError, grid.set_filter, 'name', lambda q: q) + + def test_remove_filter(self): + model = self.app.model + + # basics + with patch.object(mod.Grid, 'make_filter'): + # nb. filters are MagicMock instances + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(len(grid.filters), 2) + self.assertIn('name', grid.filters) + self.assertIn('value', grid.filters) + grid.remove_filter('value') + self.assertNotIn('value', grid.filters) + ############################## # data methods ############################## @@ -751,14 +993,27 @@ class TestGrid(WebTestCase): # data is sorted and paginated grid = self.make_grid(model_class=model.Setting, data=sample_query, + filterable=True, sortable=True, sort_on_backend=True, sort_defaults=('name', 'desc'), paginated=True, paginate_on_backend=True, pagesize=4, page=2) grid.load_settings() - visible = grid.get_visible_data() + # nb. for now the filtering is mocked + with patch.object(grid, 'filter_data') as filter_data: + filter_data.side_effect = lambda q: q + visible = grid.get_visible_data() + filter_data.assert_called_once_with(sample_query) self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2']) + def test_filter_data(self): + model = self.app.model + + query = self.session.query(model.Setting) + grid = self.make_grid(model_class=model.Setting, filterable=True) + grid.load_settings() + self.assertRaises(NotImplementedError, grid.filter_data, query) + def test_sort_data(self): model = self.app.model sample_data = [ @@ -827,6 +1082,21 @@ class TestGrid(WebTestCase): self.assertEqual(sorted_data[0]['name'], 'foo1') self.assertEqual(sorted_data[-1]['name'], 'foo9') + # now try with a joiner + query = self.session.query(model.User) + grid = self.make_grid(model_class=model.User, + data=query, + columns=['username', 'full_name'], + sortable=True, sort_on_backend=True, + sort_defaults='full_name', + joiners={ + 'full_name': lambda q: q.join(model.Person), + }) + grid.set_sorter('full_name', model.Person.full_name) + grid.load_settings() + data = grid.get_visible_data() + self.assertIsInstance(data, orm.Query) + def test_paginate_data(self): model = self.app.model sample_data = [