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>
+
+<%def name="make_wutta_filter_component()">
+
+
+%def>
+
+<%def name="make_wutta_filter_value_component()">
+
+
+%def>
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')