From 1443f5253f7de7e0b193dd74bad351c12a7b7535 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Aug 2024 13:50:29 -0500 Subject: [PATCH] feat: add initial support for proper grid filters only "text contains" filter supported so far, more to come as needed --- src/wuttaweb/grids/base.py | 408 +++++++++++---- src/wuttaweb/templates/base.mako | 27 + .../{element.mako => table_element.mako} | 0 .../templates/grids/vue_template.mako | 494 +++++++++++++----- src/wuttaweb/templates/wutta-components.mako | 96 ++++ src/wuttaweb/views/master.py | 36 +- src/wuttaweb/views/people.py | 4 + src/wuttaweb/views/roles.py | 4 + src/wuttaweb/views/settings.py | 3 + src/wuttaweb/views/users.py | 4 + tests/grids/test_base.py | 201 ++++++- tests/views/test_master.py | 6 + 12 files changed, 1060 insertions(+), 223 deletions(-) rename src/wuttaweb/templates/grids/{element.mako => table_element.mako} (100%) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index ce4f8f8..3e7695c 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -39,6 +39,7 @@ from webhelpers2.html import HTML from wuttaweb.db import Session from wuttaweb.util import FieldList, get_model_fields, make_json_safe +from wuttjamaican.util import UNSPECIFIED log = logging.getLogger(__name__) @@ -304,6 +305,12 @@ class Grid: See also :meth:`set_filter()`. + .. attribute:: filter_defaults + + Dict containing default state preferences for the filters. + + See also :meth:`set_filter_defaults()`. + .. attribute:: joiners Dict of "joiner" functions for use with backend filtering and @@ -337,6 +344,7 @@ class Grid: searchable_columns=None, filterable=False, filters=None, + filter_defaults=None, joiners=None, ): self.request = request @@ -388,6 +396,7 @@ class Grid: self.filters = self.make_backend_filters() else: self.filters = {} + self.set_filter_defaults(**(filter_defaults or {})) def get_columns(self): """ @@ -1025,14 +1034,8 @@ class Grid: 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. + Create and return a :class:`GridFilter` instance suitable for + use on the given column. Code usually does not need to call this directly. See also :meth:`set_filter()`, which calls this method automatically. @@ -1040,16 +1043,24 @@ class Grid: :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. + :returns: A :class:`GridFilter` instance. """ + model_property = None if isinstance(columninfo, str): key = columninfo + if self.model_class: + try: + mapper = sa.inspect(self.model_class) + except sa.exc.NoInspectionAvailable: + pass + else: + model_property = mapper.get_property(key) + if not model_property: + raise ValueError(f"cannot locate model property for key: {key}") else: model_property = columninfo - key = model_property.key - return GridFilter(self.request, key, **kwargs) + return GridFilter(self.request, model_property, **kwargs) def set_filter(self, key, filterinfo=None, **kwargs): """ @@ -1096,6 +1107,31 @@ class Grid: """ self.filters.pop(key, None) + def set_filter_defaults(self, **defaults): + """ + Set default state preferences for the grid filters. + + These preferences will affect the initial grid display, until + user requests a different filtering method. + + Each kwarg should be named by filter key, and the value should + be a dict of preferences for that filter. For instance:: + + grid.set_filter_defaults(name={'active': True, + 'verb': 'contains', + 'value': 'foo'}, + value={'active': True}) + + Filter defaults are tracked via :attr:`filter_defaults`. + """ + filter_defaults = dict(getattr(self, 'filter_defaults', {})) + + for key, values in defaults.items(): + filtr = filter_defaults.setdefault(key, {}) + filtr.update(values) + + self.filter_defaults = filter_defaults + ############################## # paging methods ############################## @@ -1188,9 +1224,13 @@ class Grid: 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 + defaults = self.filter_defaults.get(filtr.key, {}) + settings[f'filter.{filtr.key}.active'] = defaults.get('active', + filtr.default_active) + settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', + filtr.default_verb) + settings[f'filter.{filtr.key}.value'] = defaults.get('value', + filtr.default_value) if self.sortable: if self.sort_defaults: # nb. as of writing neither Buefy nor Oruga support a @@ -1205,21 +1245,16 @@ 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('filter'): + # nb. do not read settings if user wants a reset + if self.request.GET.get('reset-view'): + # at this point we only have default settings, and we want + # to keep those *and* persist them for next time, below + pass + + elif 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') @@ -1250,19 +1285,13 @@ 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.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb filtr.value = settings[f'filter.{filtr.key}.value'] # sorting @@ -1497,20 +1526,45 @@ class Grid: return data + @property + def active_filters(self): + """ + Returns the list of currently active filters. + + This inspects each :class:`GridFilter` in :attr:`filters` and + only returns the ones marked active. + """ + return [filtr for filtr in self.filters.values() + if filtr.active] + 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. + specified, the grid's :attr:`active_filters` are used. """ - raise NotImplementedError + if filters is None: + filters = self.active_filters + if not filters: + return data + + for filtr in filters: + key = filtr.key + + if key in self.joiners and key not in self.joined: + data = self.joiners[key](data) + self.joined.add(key) + + try: + data = filtr.apply_filter(data) + except VerbNotSupported as error: + log.warning("verb not supported for '%s' filter: %s", key, error.verb) + except: + log.exception("filtering data by '%s' failed!", key) + + return data def sort_data(self, data, sorters=None): """ @@ -1588,6 +1642,58 @@ class Grid: # rendering methods ############################## + def render_table_element( + self, + form=None, + template='/grids/table_element.mako', + **context): + """ + Render a simple Vue table element for the grid. + + This is what you want for a "simple" grid which does require a + unique Vue component, but can instead use the standard table + component. + + This returns something like: + + .. code-block:: html + + + + + + See :meth:`render_vue_template()` for a more complete variant. + + Actual output will of course depend on grid attributes, + :attr:`key`, :attr:`columns` etc. + + :param form: Reference to the + :class:`~wuttaweb.forms.base.Form` instance which + "contains" this grid. This is needed in order to ensure + the grid data is available to the form Vue component. + + :param template: Path to Mako template which is used to render + the output. + + .. note:: + + The above example shows ``gridData['mykey']`` as the Vue + data reference. This should "just work" if you provide the + correct ``form`` arg and the grid is contained directly by + that form's Vue component. + + However, this may not account for all use cases. For now + we wait and see what comes up, but know the dust may not + yet be settled here. + """ + + # nb. must register data for inclusion on page template + if form: + form.add_grid_vue_data(self) + + # otherwise logic is the same, just different template + return self.render_vue_template(template=template, **context) + def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. @@ -1649,58 +1755,6 @@ class Grid: output = render(template, context) return HTML.literal(output) - def render_table_element( - self, - form=None, - template='/grids/element.mako', - **context): - """ - Render a simple Vue table element for the grid. - - This is what you want for a "simple" grid which does require a - unique Vue component, but can instead use the standard table - component. - - This returns something like: - - .. code-block:: html - - - - - - See :meth:`render_vue_template()` for a more complete variant. - - Actual output will of course depend on grid attributes, - :attr:`key`, :attr:`columns` etc. - - :param form: Reference to the - :class:`~wuttaweb.forms.base.Form` instance which - "contains" this grid. This is needed in order to ensure - the grid data is available to the form Vue component. - - :param template: Path to Mako template which is used to render - the output. - - .. note:: - - The above example shows ``gridData['mykey']`` as the Vue - data reference. This should "just work" if you provide the - correct ``form`` arg and the grid is contained directly by - that form's Vue component. - - However, this may not account for all use cases. For now - we wait and see what comes up, but know the dust may not - yet be settled here. - """ - - # nb. must register data for inclusion on page template - if form: - form.add_grid_vue_data(self) - - # otherwise logic is the same, just different template - return self.render_vue_template(template=template, **context) - def render_vue_finalize(self): """ Render the Vue "finalize" script for the grid. @@ -1781,6 +1835,26 @@ class Grid: 'order': sorter['dir']}) return sorters + def get_vue_filters(self): + """ + Returns a list of Vue-compatible filter definitions. + + This returns the full set of :attr:`filters` but represents + each as a simple dict with the filter state. + """ + filters = [] + for filtr in self.filters.values(): + filters.append({ + 'key': filtr.key, + 'active': filtr.active, + 'visible': filtr.active, + 'verbs': filtr.verbs, + 'verb': filtr.verb, + 'value': filtr.value, + 'label': filtr.label, + }) + return filters + def get_vue_data(self): """ Returns a list of Vue-compatible data records. @@ -2009,24 +2083,162 @@ class GridAction: return self.url -# TODO: this needs plenty of work yet..and probably will move? class GridFilter: - """ """ + """ + Filter option for a grid. Represents both the "features" as well + as "state" for the filter. + + :param request: Current :term:`request` object. + + :param model_property: Property of a model class, representing the + column by which to filter. For instance, + ``model.Person.full_name``. + + :param \**kwargs: Any additional kwargs will be set as attributes + on the filter instance. + + Filter instances have the following attributes: + + .. attribute:: key + + Unique key for the filter. This often corresponds to a "column + name" for the grid, but not always. + + .. attribute:: label + + Display label for the filter field. + + .. attribute:: active + + Boolean indicating whether the filter is currently active. + + See also :attr:`verb` and :attr:`value`. + + .. attribute:: verb + + Verb for current filter, if :attr:`active` is true. + + See also :attr:`value`. + + .. attribute:: value + + Value for current filter, if :attr:`active` is true. + + See also :attr:`verb`. + + .. attribute:: default_active + + Boolean indicating whether the filter should be active by + default, i.e. when first displaying the grid. + + See also :attr:`default_verb` and :attr:`default_value`. + + .. attribute:: default_verb + + Filter verb to use by default. This will be auto-selected when + the filter is first activated, or when first displaying the + grid if :attr:`default_active` is true. + + See also :attr:`default_value`. + + .. attribute:: default_value + + Filter value to use by default. This will be auto-populated + when the filter is first activated, or when first displaying + the grid if :attr:`default_active` is true. + + See also :attr:`default_verb`. + """ def __init__( self, request, - key, + model_property, + label=None, default_active=False, default_verb=None, default_value=None, **kwargs, ): self.request = request - self.key = key + self.config = self.request.wutta_config + self.app = self.config.get_app() + + self.model_property = model_property + self.key = self.model_property.key + self.label = label or self.app.make_title(self.key) self.default_active = default_active - self.default_verb = default_verb + self.active = self.default_active + + self.verbs = ['contains'] # TODO + self.default_verb = default_verb or self.verbs[0] + self.verb = self.default_verb + self.default_value = default_value + self.value = self.default_value self.__dict__.update(kwargs) + + def __repr__(self): + return ("GridFilter(" + f"key='{self.key}', " + f"active={self.active}, " + f"verb='{self.verb}', " + f"value={repr(self.value)})") + + def apply_filter(self, data, verb=None, value=UNSPECIFIED): + """ + Filter the given data set according to a verb/value pair. + + If verb and/or value are not specified, will use :attr:`verb` + and/or :attr:`value` instead. + + This method does not directly filter the data; rather it + delegates (based on ``verb``) to some other method. The + latter may choose *not* to filter the data, e.g. if ``value`` + is empty, in which case this may return the original data set + unchanged. + + :returns: The (possibly) filtered data set. + """ + if verb is None: + verb = self.verb + if not verb: + log.warn("missing verb for '%s' filter, will use default verb: %s", + self.key, self.default_verb) + verb = self.default_verb + + if value is UNSPECIFIED: + value = self.value + + func = getattr(self, f'filter_{verb}', None) + if not func: + raise VerbNotSupported(verb) + + return func(data, value) + + def filter_contains(self, query, value): + """ + Filter data with a full 'ILIKE' query. + """ + if value is None or value == '': + return query + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = f'%{val}%' + criteria.append(self.model_property.ilike(val)) + + return query.filter(sa.and_(*criteria)) + + +class VerbNotSupported(Exception): + """ """ + + def __init__(self, verb): + self.verb = verb + + def __str__(self): + return f"unknown filter verb not supported: {self.verb}" diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index d027aa3..6c57c46 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -151,6 +151,33 @@ white-space: nowrap; } + ############################## + ## grids + ############################## + + .wutta-filter { + display: flex; + gap: 0.5rem; + } + + .wutta-filter .button.filter-toggle { + justify-content: left; + } + + .wutta-filter .button.filter-toggle, + .wutta-filter .filter-verb { + min-width: 15rem; + } + + .wutta-filter .filter-verb .select, + .wutta-filter .filter-verb .select select { + width: 100%; + } + + ############################## + ## forms + ############################## + .wutta-form-wrapper { margin-left: 5rem; margin-top: 2rem; diff --git a/src/wuttaweb/templates/grids/element.mako b/src/wuttaweb/templates/grids/table_element.mako similarity index 100% rename from src/wuttaweb/templates/grids/element.mako rename to src/wuttaweb/templates/grids/table_element.mako diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index da87296..58721a2 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -1,130 +1,215 @@ ## -*- coding: utf-8; -*- + +<%def name="make_wutta_filter_component()"> + + + + +<%def name="make_wutta_filter_value_component()"> + + + diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 6b20f0a..4258cfd 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,6 +181,23 @@ class MasterView(View): This is optional; see also :meth:`get_grid_columns()`. + .. attribute:: filterable + + Boolean indicating whether the grid for the :meth:`index()` + view should allow filtering of data. Default is ``True``. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.filterable` flag. + + .. attribute:: filter_defaults + + Optional dict of default filter state. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.filter_defaults`. + + Only relevant if :attr:`filterable` is true. + .. attribute:: sortable Boolean indicating whether the grid for the :meth:`index()` @@ -283,6 +300,8 @@ class MasterView(View): # features listable = True has_grid = True + filterable = True + filter_defaults = None sortable = True sort_on_backend = True sort_defaults = None @@ -337,13 +356,26 @@ class MasterView(View): if self.has_grid: grid = self.make_model_grid() - # so-called 'partial' requests get just data, no html + # handle "full" vs. "partial" differently if self.request.GET.get('partial'): + + # so-called 'partial' requests get just data, no html context = {'data': grid.get_vue_data()} if grid.paginated and grid.paginate_on_backend: context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) + else: # full, not partial + + # nb. when user asks to reset view, it is via the query + # string. if so we then redirect to discard that. + if self.request.GET.get('reset-view'): + + # nb. we want to preserve url hash if applicable + kw = {'_query': None, + '_anchor': self.request.GET.get('hash')} + return self.redirect(self.request.current_route_url(**kw)) + context['grid'] = grid return self.render_to_response('index', context) @@ -1208,6 +1240,8 @@ class MasterView(View): kwargs['actions'] = actions + kwargs.setdefault('filterable', self.filterable) + kwargs.setdefault('filter_defaults', self.filter_defaults) kwargs.setdefault('sortable', self.sortable) kwargs.setdefault('sort_multiple', not self.request.use_oruga) kwargs.setdefault('sort_on_backend', self.sort_on_backend) diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index 7aa7596..a19df57 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -58,6 +58,10 @@ class PersonView(MasterView): 'last_name', ] + filter_defaults = { + 'full_name': {'active': True}, + } + def configure_grid(self, g): """ """ super().configure_grid(g) diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index a8f60e4..fa7c8fc 100644 --- a/src/wuttaweb/views/roles.py +++ b/src/wuttaweb/views/roles.py @@ -52,6 +52,10 @@ class RoleView(MasterView): 'notes', ] + filter_defaults = { + 'name': {'active': True}, + } + # TODO: master should handle this, possibly via configure_form() def get_query(self, session=None): """ """ diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 5b24d86..a20e1f6 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -201,6 +201,9 @@ class SettingView(MasterView): """ model_class = Setting model_title = "Raw Setting" + filter_defaults = { + 'name': {'active': True}, + } sort_defaults = 'name' # TODO: master should handle this (per model key) diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 4f4b6f0..d05b8eb 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -55,6 +55,10 @@ class UserView(MasterView): 'active', ] + filter_defaults = { + 'username': {'active': True}, + } + # TODO: master should handle this, possibly via configure_form() def get_query(self, session=None): """ """ diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 3cef14e..5726367 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -383,8 +383,7 @@ class TestGrid(WebTestCase): 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.assertEqual(len(grid.active_filters), 0) 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'} @@ -401,8 +400,7 @@ class TestGrid(WebTestCase): 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.assertEqual(len(grid.active_filters), 0) 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) @@ -419,6 +417,12 @@ class TestGrid(WebTestCase): self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc') + # can reset view to defaults + self.request.GET = {'reset-view': 'true'} + grid.load_settings() + self.assertEqual(grid.active_filters, []) + self.assertIsNone(grid.filters['name'].value) + def test_request_has_settings(self): model = self.app.model grid = self.make_grid(key='settings', model_class=model.Setting) @@ -927,6 +931,10 @@ class TestGrid(WebTestCase): filtr = grid.make_filter(model.Setting.name) self.assertIsInstance(filtr, mod.GridFilter) + # invalid model class + grid = self.make_grid(model_class=42) + self.assertRaises(ValueError, grid.make_filter, 'name') + def test_set_filter(self): model = self.app.model @@ -968,6 +976,22 @@ class TestGrid(WebTestCase): grid.remove_filter('value') self.assertNotIn('value', grid.filters) + def test_set_filter_defaults(self): + model = self.app.model + + # empty by default + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(grid.filter_defaults, {}) + + # can specify via method call + grid.set_filter_defaults(name={'active': True}) + self.assertEqual(grid.filter_defaults, {'name': {'active': True}}) + + # can specify via constructor + grid = self.make_grid(model_class=model.Setting, filterable=True, + filter_defaults={'name': {'active': True}}) + self.assertEqual(grid.filter_defaults, {'name': {'active': True}}) + ############################## # data methods ############################## @@ -1008,11 +1032,82 @@ class TestGrid(WebTestCase): def test_filter_data(self): model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'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) - query = self.session.query(model.Setting) - grid = self.make_grid(model_class=model.Setting, filterable=True) + grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) + + # not filtered by default grid.load_settings() - self.assertRaises(NotImplementedError, grid.filter_data, query) + self.assertEqual(grid.active_filters, []) + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + + # can be filtered per session settings + self.request.session['grid.settings.filter.value.active'] = True + self.request.session['grid.settings.filter.value.verb'] = 'contains' + self.request.session['grid.settings.filter.value.value'] = 'ggg' + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + filtered_query = grid.filter_data(sample_query) + self.assertIsInstance(filtered_query, orm.Query) + self.assertIsNot(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 3) + + # can be filtered per request settings + self.request.GET = {'value': 's', 'value.verb': 'contains'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + filtered_query = grid.filter_data(sample_query) + self.assertIsInstance(filtered_query, orm.Query) + self.assertEqual(filtered_query.count(), 2) + + # not filtered if verb is invalid + self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].verb, 'doesnotexist') + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 9) + + # not filtered if error + self.request.GET = {'value': 'ggg', 'value.verb': 'contains'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].verb, 'contains') + filtered_query = grid.filter_data(sample_query) + self.assertIsNot(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 3) + with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError): + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 9) + + # joiner is invoked + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + joiner = MagicMock(side_effect=lambda q: q) + grid.joiners = {'value': joiner} + grid.joined = set() + filtered_query = grid.filter_data(sample_query) + joiner.assert_called_once_with(sample_query) + self.assertEqual(filtered_query.count(), 3) def test_sort_data(self): model = self.app.model @@ -1210,6 +1305,15 @@ class TestGrid(WebTestCase): sorters = grid.get_vue_active_sorters() self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}]) + def test_get_vue_filters(self): + model = self.app.model + + # basic + grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) + grid.load_settings() + filters = grid.get_vue_filters() + self.assertEqual(len(filters), 2) + def test_get_vue_data(self): # empty if no columns defined @@ -1317,3 +1421,86 @@ class TestGridAction(TestCase): action = self.make_action('blarg', url=lambda o, i: '/yeehaw') url = action.get_url(obj) self.assertEqual(url, '/yeehaw') + + +class TestGridFilter(WebTestCase): + + def setUp(self): + self.setup_web() + + model = self.app.model + self.sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in self.sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + self.sample_query = self.session.query(model.Setting) + + def make_filter(self, model_property, **kwargs): + return mod.GridFilter(self.request, model_property, **kwargs) + + def test_repr(self): + model = self.app.model + filtr = self.make_filter(model.Setting.name) + self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb='contains', value=None)") + + def test_apply_filter(self): + model = self.app.model + filtr = self.make_filter(model.Setting.value) + + # default verb used as fallback + self.assertEqual(filtr.default_verb, 'contains') + filtr.verb = None + with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains: + filtered_query = filtr.apply_filter(self.sample_query, value='foo') + filter_contains.assert_called_once_with(self.sample_query, 'foo') + self.assertIsNone(filtr.verb) + + # filter verb used as fallback + filtr.verb = 'equal' + with patch.object(filtr, 'filter_equal', create=True, side_effect=lambda q, v: q) as filter_equal: + filtered_query = filtr.apply_filter(self.sample_query, value='foo') + filter_equal.assert_called_once_with(self.sample_query, 'foo') + + # filter value used as fallback + filtr.verb = 'contains' + filtr.value = 'blarg' + with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains: + filtered_query = filtr.apply_filter(self.sample_query) + filter_contains.assert_called_once_with(self.sample_query, 'blarg') + + # error if invalid verb + self.assertRaises(mod.VerbNotSupported, filtr.apply_filter, + self.sample_query, verb='doesnotexist') + + def test_filter_contains(self): + model = self.app.model + filtr = self.make_filter(model.Setting.value) + self.assertEqual(self.sample_query.count(), 9) + + # not filtered for empty value + filtered_query = filtr.filter_contains(self.sample_query, None) + self.assertIs(filtered_query, self.sample_query) + filtered_query = filtr.filter_contains(self.sample_query, '') + self.assertIs(filtered_query, self.sample_query) + + # filtered by value + filtered_query = filtr.filter_contains(self.sample_query, 'ggg') + self.assertIsNot(filtered_query, self.sample_query) + self.assertEqual(filtered_query.count(), 3) + + +class TestVerbNotSupported(TestCase): + + def test_basic(self): + error = mod.VerbNotSupported('equal') + self.assertEqual(str(error), "unknown filter verb not supported: equal") diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 6660cd6..7273265 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -761,6 +761,12 @@ class TestMasterView(WebTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.content_type, 'application/json') + # redirects when view is reset + self.request.GET = {'reset-view': '1', 'hash': 'foo'} + with patch.object(self.request, 'current_route_url'): + response = view.index() + self.assertEqual(response.status_code, 302) + def test_create(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth')