2
0
Fork 0

feat: add initial filtering logic to grid class

still missing the actual filters, subclass must provide those for now
This commit is contained in:
Lance Edgar 2024-08-21 20:15:23 -05:00
parent a042d511fb
commit 9751bf4c2e
2 changed files with 633 additions and 20 deletions

View file

@ -288,6 +288,28 @@ class Grid:
Set of columns declared as searchable for the Vue component. Set of columns declared as searchable for the Vue component.
See also :meth:`set_searchable()` and :meth:`is_searchable()`. 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__( def __init__(
@ -313,6 +335,9 @@ class Grid:
pagesize=None, pagesize=None,
page=1, page=1,
searchable_columns=None, searchable_columns=None,
filterable=False,
filters=None,
joiners=None,
): ):
self.request = request self.request = request
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
@ -323,6 +348,7 @@ class Grid:
self.renderers = renderers or {} self.renderers = renderers or {}
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or [] self.linked_columns = linked_columns or []
self.joiners = joiners or {}
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
@ -354,6 +380,15 @@ class Grid:
# searching # searching
self.searchable_columns = set(searchable_columns or []) 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): def get_columns(self):
""" """
Returns the official list of column names for the grid, or Returns the official list of column names for the grid, or
@ -442,7 +477,7 @@ class Grid:
if key in self.columns: if key in self.columns:
self.columns.remove(key) 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. Set/override the label for a column.
@ -450,11 +485,18 @@ class Grid:
:param label: New label for the column header. :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 See also :meth:`get_label()`. Label overrides are tracked via
:attr:`labels`. :attr:`labels`.
""" """
self.labels[key] = label self.labels[key] = label
if not column_only and key in self.filters:
self.filters[key].label = label
def get_label(self, key): def get_label(self, key):
""" """
Returns the label text for a given column. Returns the label text for a given column.
@ -582,6 +624,63 @@ class Grid:
""" """
self.actions.append(GridAction(self.request, key, **kwargs)) 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 # sorting methods
############################## ##############################
@ -883,6 +982,120 @@ class Grid:
return key in self.sorters return key in self.sorters
return True 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 # paging methods
############################## ##############################
@ -973,6 +1186,11 @@ class Grid:
# initial default settings # initial default settings
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.sortable:
if self.sort_defaults: if self.sort_defaults:
# nb. as of writing neither Buefy nor Oruga support a # nb. as of writing neither Buefy nor Oruga support a
@ -987,14 +1205,35 @@ class Grid:
settings['pagesize'] = self.pagesize settings['pagesize'] = self.pagesize
settings['page'] = self.page 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 # update settings dict based on what we find in the request
# and/or user session. always prioritize the former. # 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_sort_settings(settings, src='request')
self.update_page_settings(settings) self.update_page_settings(settings)
elif self.request_has_settings('page'): elif self.request_has_settings('page'):
self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session') self.update_sort_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
@ -1003,6 +1242,7 @@ class Grid:
persist = False persist = False
# but still should load whatever is in user session # 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_sort_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
@ -1010,8 +1250,21 @@ class Grid:
if persist: if persist:
self.persist_settings(settings, dest='session') 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.. # 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 # sorting
if self.sortable: if self.sortable:
# nb. doing this for frontend sorting also # nb. doing this for frontend sorting also
@ -1036,11 +1289,18 @@ class Grid:
def request_has_settings(self, typ): 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: if 'sort1key' in self.request.GET:
return True return True
elif typ == 'page': elif typ == 'page' and self.paginated and self.paginate_on_backend:
for key in ['pagesize', 'page']: for key in ['pagesize', 'page']:
if key in self.request.GET: if key in self.request.GET:
return True return True
@ -1072,6 +1332,31 @@ class Grid:
# okay then, default it is # okay then, default it is
return default 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): def update_sort_settings(self, settings, src=None):
""" """ """ """
if not (self.sortable and self.sort_on_backend): if not (self.sortable and self.sort_on_backend):
@ -1138,8 +1423,18 @@ class Grid:
skey = f'grid.{self.key}.{key}' skey = f'grid.{self.key}.{key}'
self.request.session[skey] = value(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 # sort settings
if self.sortable: if self.sortable and self.sort_on_backend:
# first must clear all sort settings from dest. this is # first must clear all sort settings from dest. this is
# because number of sort settings will vary, so we delete # 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: See also these methods which may be called by this one:
* :meth:`filter_data()`
* :meth:`sort_data()` * :meth:`sort_data()`
* :meth:`paginate_data()` * :meth:`paginate_data()`
""" """
data = self.data or [] data = self.data or []
self.joined = set()
if self.filterable:
data = self.filter_data(data)
if self.sortable and self.sort_on_backend: if self.sortable and self.sort_on_backend:
data = self.sort_data(data) data = self.sort_data(data)
@ -1197,6 +1497,21 @@ class Grid:
return data 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): def sort_data(self, data, sorters=None):
""" """
Sort the given data and return the result. This is called by Sort the given data and return the result. This is called by
@ -1227,6 +1542,11 @@ class Grid:
if not sortfunc: if not sortfunc:
return data 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 # invoke the sorter
data = sortfunc(data, sortdir) data = sortfunc(data, sortdir)
@ -1687,3 +2007,26 @@ class GridAction:
return self.url(obj, i) return self.url(obj, i)
return self.url 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)

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch, MagicMock
from sqlalchemy import orm from sqlalchemy import orm
from paginate import Page from paginate import Page
@ -86,6 +86,32 @@ class TestGrid(WebTestCase):
sort_multiple=True) sort_multiple=True)
self.assertFalse(grid.sort_multiple) 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): def test_vue_tagname(self):
grid = self.make_grid() grid = self.make_grid()
self.assertEqual(grid.vue_tagname, 'wutta-grid') self.assertEqual(grid.vue_tagname, 'wutta-grid')
@ -125,17 +151,28 @@ class TestGrid(WebTestCase):
self.assertEqual(grid.columns, ['one', 'four']) self.assertEqual(grid.columns, ['one', 'four'])
def test_set_label(self): 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, {}) self.assertEqual(grid.labels, {})
# basic # basic
grid.set_label('foo', "Foo Fighters") grid.set_label('name', "NAME COL")
self.assertEqual(grid.labels['foo'], "Foo Fighters") self.assertEqual(grid.labels['name'], "NAME COL")
# can replace label # can replace label
grid.set_label('foo', "Different") grid.set_label('name', "Different")
self.assertEqual(grid.labels['foo'], "Different") self.assertEqual(grid.labels['name'], "Different")
self.assertEqual(grid.get_label('foo'), "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): def test_get_label(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
@ -342,20 +379,72 @@ class TestGrid(WebTestCase):
grid.load_settings() grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) 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): 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 # paging
self.assertFalse(grid.request_has_settings('page')) self.assertFalse(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'pagesize': '20'}): with patch.object(grid, 'paginated', new=True):
self.assertTrue(grid.request_has_settings('page')) with patch.object(self.request, 'GET', new={'pagesize': '20'}):
with patch.object(self.request, 'GET', new={'page': '1'}): self.assertTrue(grid.request_has_settings('page'))
self.assertTrue(grid.request_has_settings('page')) with patch.object(self.request, 'GET', new={'page': '1'}):
self.assertTrue(grid.request_has_settings('page'))
# sorting # sorting
self.assertFalse(grid.request_has_settings('sort')) self.assertFalse(grid.request_has_settings('sort'))
with patch.object(self.request, 'GET', new={'sort1key': 'name'}): with patch.object(grid, 'sortable', new=True):
self.assertTrue(grid.request_has_settings('sort')) 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): def test_get_setting(self):
grid = self.make_grid(key='foo') grid = self.make_grid(key='foo')
@ -400,6 +489,40 @@ class TestGrid(WebTestCase):
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int) value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 35) 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): def test_update_sort_settings(self):
model = self.app.model 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.key', self.request.session)
self.assertNotIn('grid.settings.sorters.2.dir', 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 # sorting methods
############################## ##############################
@ -629,6 +771,23 @@ class TestGrid(WebTestCase):
sorted_data = sorter(sample_data, 'asc') sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'}) 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): def test_set_sorter(self):
model = self.app.model model = self.app.model
@ -726,6 +885,89 @@ class TestGrid(WebTestCase):
grid.sortable = False grid.sortable = False
self.assertFalse(grid.is_sortable('name')) 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 # data methods
############################## ##############################
@ -751,14 +993,27 @@ class TestGrid(WebTestCase):
# data is sorted and paginated # data is sorted and paginated
grid = self.make_grid(model_class=model.Setting, grid = self.make_grid(model_class=model.Setting,
data=sample_query, data=sample_query,
filterable=True,
sortable=True, sort_on_backend=True, sortable=True, sort_on_backend=True,
sort_defaults=('name', 'desc'), sort_defaults=('name', 'desc'),
paginated=True, paginate_on_backend=True, paginated=True, paginate_on_backend=True,
pagesize=4, page=2) pagesize=4, page=2)
grid.load_settings() 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']) 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): def test_sort_data(self):
model = self.app.model model = self.app.model
sample_data = [ sample_data = [
@ -827,6 +1082,21 @@ class TestGrid(WebTestCase):
self.assertEqual(sorted_data[0]['name'], 'foo1') self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9') 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): def test_paginate_data(self):
model = self.app.model model = self.app.model
sample_data = [ sample_data = [