diff --git a/docs/api/wuttaweb/grids.filters.rst b/docs/api/wuttaweb/grids.filters.rst
new file mode 100644
index 0000000..b929d75
--- /dev/null
+++ b/docs/api/wuttaweb/grids.filters.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.grids.filters``
+==========================
+
+.. automodule:: wuttaweb.grids.filters
+ :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 1410a20..9749cae 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -16,6 +16,7 @@
forms.widgets
grids
grids.base
+ grids.filters
handler
helpers
menus
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 3e7695c..0ec15f3 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -40,6 +40,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
+from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
log = logging.getLogger(__name__)
@@ -1034,8 +1035,9 @@ class Grid:
def make_filter(self, columninfo, **kwargs):
"""
- Create and return a :class:`GridFilter` instance suitable for
- use on the given column.
+ Create and return a
+ :class:`~wuttaweb.grids.filters.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.
@@ -1043,24 +1045,34 @@ class Grid:
:param columninfo: Can be either a model property (see below),
or a column name.
- :returns: A :class:`GridFilter` instance.
+ :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
+ instance.
"""
+
+ # model_property is required
model_property = None
- if isinstance(columninfo, str):
+ if kwargs.get('model_property'):
+ model_property = kwargs['model_property']
+ elif 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)
+ model_property = getattr(self.model_class, key, None)
if not model_property:
raise ValueError(f"cannot locate model property for key: {key}")
else:
model_property = columninfo
- return GridFilter(self.request, model_property, **kwargs)
+ # optional factory override
+ factory = kwargs.pop('factory', None)
+ if not factory:
+ typ = model_property.type
+ factory = default_sqlalchemy_filters.get(type(typ))
+ if not factory:
+ factory = default_sqlalchemy_filters[None]
+
+ # make filter
+ kwargs['model_property'] = model_property
+ return factory(self.request, model_property.key, **kwargs)
def set_filter(self, key, filterinfo=None, **kwargs):
"""
@@ -1228,7 +1240,7 @@ class Grid:
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
filtr.default_active)
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
- filtr.default_verb)
+ filtr.get_default_verb())
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
filtr.default_value)
if self.sortable:
@@ -1291,7 +1303,7 @@ class Grid:
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'] or filtr.default_verb
+ filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb()
filtr.value = settings[f'filter.{filtr.key}.value']
# sorting
@@ -1531,8 +1543,8 @@ class Grid:
"""
Returns the list of currently active filters.
- This inspects each :class:`GridFilter` in :attr:`filters` and
- only returns the ones marked active.
+ This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
+ in :attr:`filters` and only returns the ones marked active.
"""
return [filtr for filtr in self.filters.values()
if filtr.active]
@@ -1848,7 +1860,9 @@ class Grid:
'key': filtr.key,
'active': filtr.active,
'visible': filtr.active,
- 'verbs': filtr.verbs,
+ 'verbs': filtr.get_verbs(),
+ 'verb_labels': filtr.get_verb_labels(),
+ 'valueless_verbs': filtr.get_valueless_verbs(),
'verb': filtr.verb,
'value': filtr.value,
'label': filtr.label,
@@ -2081,164 +2095,3 @@ class GridAction:
return self.url(obj, i)
return self.url
-
-
-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,
- model_property,
- label=None,
- default_active=False,
- default_verb=None,
- default_value=None,
- **kwargs,
- ):
- self.request = request
- 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.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/grids/filters.py b/src/wuttaweb/grids/filters.py
new file mode 100644
index 0000000..0489c22
--- /dev/null
+++ b/src/wuttaweb/grids/filters.py
@@ -0,0 +1,444 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Grid Filters
+"""
+
+import logging
+
+import sqlalchemy as sa
+
+from wuttjamaican.util import UNSPECIFIED
+
+
+log = logging.getLogger(__name__)
+
+
+class VerbNotSupported(Exception):
+ """ """
+
+ def __init__(self, verb):
+ self.verb = verb
+
+ def __str__(self):
+ return f"unknown filter verb not supported: {self.verb}"
+
+
+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`.
+ """
+ default_verbs = ['equal', 'not_equal']
+
+ default_verb_labels = {
+ 'is_any': "is any",
+ 'equal': "equal to",
+ 'not_equal': "not equal to",
+ 'is_null': "is null",
+ 'is_not_null': "is not null",
+ 'is_true': "is true",
+ 'is_false': "is false",
+ 'contains': "contains",
+ 'does_not_contain': "does not contain",
+ }
+
+ valueless_verbs = [
+ 'is_any',
+ 'is_null',
+ 'is_not_null',
+ 'is_true',
+ 'is_false',
+ ]
+
+ def __init__(
+ self,
+ request,
+ key,
+ label=None,
+ verbs=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.label = label or self.app.make_title(self.key)
+
+ # active
+ self.default_active = default_active
+ self.active = self.default_active
+
+ # verb
+ if verbs is not None:
+ self.verbs = verbs
+ if default_verb:
+ self.default_verb = default_verb
+
+ # value
+ self.default_value = default_value
+ self.value = self.default_value
+
+ self.__dict__.update(kwargs)
+
+ def __repr__(self):
+ verb = getattr(self, 'verb', None)
+ return (f"{self.__class__.__name__}("
+ f"key='{self.key}', "
+ f"active={self.active}, "
+ f"verb={repr(verb)}, "
+ f"value={repr(self.value)})")
+
+ def get_verbs(self):
+ """
+ Returns the list of verbs supported by the filter.
+ """
+ verbs = None
+
+ if hasattr(self, 'verbs'):
+ verbs = self.verbs
+
+ else:
+ verbs = self.default_verbs
+
+ if callable(verbs):
+ verbs = verbs()
+ verbs = list(verbs)
+
+ if self.nullable:
+ if 'is_null' not in verbs:
+ verbs.append('is_null')
+ if 'is_not_null' not in verbs:
+ verbs.append('is_not_null')
+
+ if 'is_any' not in verbs:
+ verbs.append('is_any')
+
+ return verbs
+
+ def get_verb_labels(self):
+ """
+ Returns a dict of all defined verb labels.
+ """
+ # TODO: should traverse hierarchy
+ labels = dict([(verb, verb) for verb in self.get_verbs()])
+ labels.update(self.default_verb_labels)
+ return labels
+
+ def get_valueless_verbs(self):
+ """
+ Returns a list of verb names which do not need a value.
+ """
+ return self.valueless_verbs
+
+ def get_default_verb(self):
+ """
+ Returns the default verb for the filter.
+ """
+ verb = None
+
+ if hasattr(self, 'default_verb'):
+ verb = self.default_verb
+
+ elif hasattr(self, 'verb'):
+ verb = self.verb
+
+ if not verb:
+ verbs = self.get_verbs()
+ if verbs:
+ verb = verbs[0]
+
+ return verb
+
+ 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:
+ verb = self.get_default_verb()
+ log.warn("missing verb for '%s' filter, will use default verb: %s",
+ self.key, verb)
+
+ # only attempt for known verbs
+ if verb not in self.get_verbs():
+ raise VerbNotSupported(verb)
+
+ # fallback value
+ if value is UNSPECIFIED:
+ value = self.value
+
+ # locate filter method
+ func = getattr(self, f'filter_{verb}', None)
+ if not func:
+ raise VerbNotSupported(verb)
+
+ # invoke filter method
+ return func(data, value)
+
+ def filter_is_any(self, data, value):
+ """
+ This is a no-op which always ignores the value and returns the
+ data as-is.
+ """
+ return data
+
+
+class AlchemyFilter(GridFilter):
+ """
+ Filter option for a grid with SQLAlchemy query data.
+
+ This is a subclass of :class:`GridFilter`. It requires a
+ ``model_property`` to know how to filter the query.
+
+ :param model_property: Property of a model class, representing the
+ column by which to filter. For instance,
+ ``model.Person.full_name``.
+
+ :param nullable: Boolean indicating whether the filter should
+ include ``is_null`` and ``is_not_null`` verbs. If not
+ specified, the column will be inspected and use its nullable
+ flag.
+ """
+
+ def __init__(self, *args, **kwargs):
+ nullable = kwargs.pop('nullable', None)
+ super().__init__(*args, **kwargs)
+
+ self.nullable = nullable
+ if self.nullable is None:
+ columns = self.model_property.prop.columns
+ if len(columns) == 1:
+ self.nullable = columns[0].nullable
+
+ def coerce_value(self, value):
+ """
+ Coerce the given value to the correct type/format for use with
+ the filter.
+
+ Default logic returns value as-is; subclass may override.
+ """
+ return value
+
+ def filter_equal(self, query, value):
+ """
+ Filter data with an equal (``=``) condition.
+ """
+ value = self.coerce_value(value)
+ if value is None:
+ return query
+
+ return query.filter(self.model_property == value)
+
+ def filter_not_equal(self, query, value):
+ """
+ Filter data with a not equal (``!=``) condition.
+ """
+ value = self.coerce_value(value)
+ if value is None:
+ return query
+
+ # sql probably excludes null values from results, but user
+ # probably does not expect that, so explicitly include them.
+ return query.filter(sa.or_(
+ self.model_property == None,
+ self.model_property != value,
+ ))
+
+ def filter_is_null(self, query, value):
+ """
+ Filter data with an ``IS NULL`` query. The value is ignored.
+ """
+ return query.filter(self.model_property == None)
+
+ def filter_is_not_null(self, query, value):
+ """
+ Filter data with an ``IS NOT NULL`` query. The value is
+ ignored.
+ """
+ return query.filter(self.model_property != None)
+
+
+class StringAlchemyFilter(AlchemyFilter):
+ """
+ SQLAlchemy filter option for a text data column.
+
+ Subclass of :class:`AlchemyFilter`.
+ """
+ default_verbs = ['contains', 'does_not_contain',
+ 'equal', 'not_equal']
+
+ def coerce_value(self, value):
+ """ """
+ if value is not None:
+ value = str(value)
+ if value:
+ return value
+
+ def filter_contains(self, query, value):
+ """
+ Filter data with an ``ILIKE`` condition.
+ """
+ value = self.coerce_value(value)
+ if not 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))
+
+ def filter_does_not_contain(self, query, value):
+ """
+ Filter data with a ``NOT ILIKE`` condition.
+ """
+ value = self.coerce_value(value)
+ if not value:
+ return query
+
+ criteria = []
+ for val in value.split():
+ val = val.replace('_', r'\_')
+ val = f'%{val}%'
+ criteria.append(~self.model_property.ilike(val))
+
+ # sql probably excludes null values from results, but user
+ # probably does not expect that, so explicitly include them.
+ return query.filter(sa.or_(
+ self.model_property == None,
+ sa.and_(*criteria)))
+
+
+class BooleanAlchemyFilter(AlchemyFilter):
+ """
+ SQLAlchemy filter option for a boolean data column.
+
+ Subclass of :class:`AlchemyFilter`.
+ """
+ default_verbs = ['is_true', 'is_false']
+
+ def coerce_value(self, value):
+ """ """
+ if value is not None:
+ return bool(value)
+
+ def filter_is_true(self, query, value):
+ """
+ Filter data with an "is true" condition. The value is
+ ignored.
+ """
+ return query.filter(self.model_property == True)
+
+ def filter_is_false(self, query, value):
+ """
+ Filter data with an "is false" condition. The value is
+ ignored.
+ """
+ return query.filter(self.model_property == False)
+
+
+default_sqlalchemy_filters = {
+ None: AlchemyFilter,
+ sa.String: StringAlchemyFilter,
+ sa.Text: StringAlchemyFilter,
+ sa.Boolean: BooleanAlchemyFilter,
+}
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
index 566f419..3145c1f 100644
--- a/src/wuttaweb/templates/wutta-components.mako
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -90,16 +90,18 @@
@@ -116,9 +118,26 @@
methods: {
- focusValue: function() {
+ focusValue() {
this.$refs.filterValue.focus()
- }
+ },
+
+ valuedVerb() {
+ /* return true if the current verb should expose value input(s) */
+
+ // if filter has no "valueless" verbs, then all verbs should expose value inputs
+ if (!this.filter.valueless_verbs) {
+ return true
+ }
+
+ // if filter *does* have valueless verbs, check if "current" verb is valueless
+ if (this.filter.valueless_verbs.includes(this.filter.verb)) {
+ return false
+ }
+
+ // current verb is *not* valueless
+ return true
+ },
}
}
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index 5726367..58d19c5 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -3,6 +3,7 @@
from unittest import TestCase
from unittest.mock import patch, MagicMock
+import sqlalchemy as sa
from sqlalchemy import orm
from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
@@ -10,6 +11,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
+from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from tests.util import WebTestCase
@@ -921,20 +923,38 @@ class TestGrid(WebTestCase):
def test_make_filter(self):
model = self.app.model
- # basic
+ # arg is column name
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter('name')
- self.assertIsInstance(filtr, mod.GridFilter)
+ self.assertIsInstance(filtr, StringAlchemyFilter)
- # property
- grid = self.make_grid(model_class=model.Setting)
- filtr = grid.make_filter(model.Setting.name)
- self.assertIsInstance(filtr, mod.GridFilter)
-
- # invalid model class
+ # arg is column name, but model class is invalid
grid = self.make_grid(model_class=42)
self.assertRaises(ValueError, grid.make_filter, 'name')
+ # arg is model property
+ grid = self.make_grid(model_class=model.Setting)
+ filtr = grid.make_filter(model.Setting.name)
+ self.assertIsInstance(filtr, StringAlchemyFilter)
+
+ # model property as kwarg
+ grid = self.make_grid(model_class=model.Setting)
+ filtr = grid.make_filter(None, model_property=model.Setting.name)
+ self.assertIsInstance(filtr, StringAlchemyFilter)
+
+ # default factory
+ grid = self.make_grid(model_class=model.Setting)
+ with patch.dict(default_sqlalchemy_filters, {None: GridFilter}, clear=True):
+ filtr = grid.make_filter(model.Setting.name)
+ self.assertIsInstance(filtr, GridFilter)
+ self.assertNotIsInstance(filtr, StringAlchemyFilter)
+
+ # factory override
+ grid = self.make_grid(model_class=model.Setting)
+ filtr = grid.make_filter(model.Setting.name, factory=GridFilter)
+ self.assertIsInstance(filtr, GridFilter)
+ self.assertNotIsInstance(filtr, StringAlchemyFilter)
+
def test_set_filter(self):
model = self.app.model
@@ -1049,6 +1069,9 @@ class TestGrid(WebTestCase):
sample_query = self.session.query(model.Setting)
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
+ self.assertEqual(list(grid.filters), ['name', 'value'])
+ self.assertIsInstance(grid.filters['name'], StringAlchemyFilter)
+ self.assertIsInstance(grid.filters['value'], StringAlchemyFilter)
# not filtered by default
grid.load_settings()
@@ -1421,86 +1444,3 @@ 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/grids/test_filters.py b/tests/grids/test_filters.py
new file mode 100644
index 0000000..2d8afdc
--- /dev/null
+++ b/tests/grids/test_filters.py
@@ -0,0 +1,385 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import patch
+
+from wuttaweb.grids import filters as mod
+from tests.util import WebTestCase
+
+
+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):
+ factory = kwargs.pop('factory', mod.GridFilter)
+ kwargs['model_property'] = model_property
+ return factory(self.request, model_property.key, **kwargs)
+
+ def test_constructor(self):
+ model = self.app.model
+
+ # verbs is not set by default, but can be set
+ filtr = self.make_filter(model.Setting.name)
+ self.assertFalse(hasattr(filtr, 'verbs'))
+ filtr = self.make_filter(model.Setting.name, verbs=['foo', 'bar'])
+ self.assertEqual(filtr.verbs, ['foo', 'bar'])
+
+ # verb is not set by default, but can be set
+ filtr = self.make_filter(model.Setting.name)
+ self.assertFalse(hasattr(filtr, 'verb'))
+ filtr = self.make_filter(model.Setting.name, verb='foo')
+ self.assertEqual(filtr.verb, 'foo')
+
+ # default verb is not set by default, but can be set
+ filtr = self.make_filter(model.Setting.name)
+ self.assertFalse(hasattr(filtr, 'default_verb'))
+ filtr = self.make_filter(model.Setting.name, default_verb='foo')
+ self.assertEqual(filtr.default_verb, 'foo')
+
+ def test_repr(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.name, factory=mod.GridFilter)
+ self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb=None, value=None)")
+
+ def test_get_verbs(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+ self.assertFalse(hasattr(filtr, 'verbs'))
+ self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
+
+ # by default, returns default verbs (plus 'is_any')
+ self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+ # default verbs can be a callable
+ filtr.default_verbs = lambda: ['foo', 'bar']
+ self.assertEqual(filtr.get_verbs(), ['foo', 'bar', 'is_any'])
+
+ # uses filtr.verbs if set
+ filtr.verbs = ['is_true', 'is_false']
+ self.assertEqual(filtr.get_verbs(), ['is_true', 'is_false', 'is_any'])
+
+ # may add is/null verbs
+ filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter,
+ nullable=True)
+ self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal',
+ 'is_null', 'is_not_null',
+ 'is_any'])
+
+ # filtr.verbs can be a callable
+ filtr.nullable = False
+ filtr.verbs = lambda: ['baz', 'blarg']
+ self.assertEqual(filtr.get_verbs(), ['baz', 'blarg', 'is_any'])
+
+ def test_get_default_verb(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+ self.assertFalse(hasattr(filtr, 'verbs'))
+ self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
+ self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+ # returns first verb by default
+ self.assertEqual(filtr.get_default_verb(), 'equal')
+
+ # returns filtr.verb if set
+ filtr.verb = 'foo'
+ self.assertEqual(filtr.get_default_verb(), 'foo')
+
+ # returns filtr.default_verb if set
+ # (nb. this overrides filtr.verb since the point of this
+ # method is to return the *default* verb)
+ filtr.default_verb = 'bar'
+ self.assertEqual(filtr.get_default_verb(), 'bar')
+
+ def test_get_verb_labels(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+ self.assertFalse(hasattr(filtr, 'verbs'))
+ self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+ labels = filtr.get_verb_labels()
+ self.assertIsInstance(labels, dict)
+ self.assertEqual(labels['equal'], "equal to")
+ self.assertEqual(labels['not_equal'], "not equal to")
+ self.assertEqual(labels['is_any'], "is any")
+
+ def test_get_valueless_verbs(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+ self.assertFalse(hasattr(filtr, 'verbs'))
+ self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+ verbs = filtr.get_valueless_verbs()
+ self.assertIsInstance(verbs, list)
+ self.assertIn('is_any', verbs)
+
+ def test_apply_filter(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter)
+
+ # default verb used as fallback
+ # self.assertEqual(filtr.default_verb, 'contains')
+ 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')
+ filtr.verbs = ['doesnotexist']
+ self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
+ self.sample_query, verb='doesnotexist')
+
+ def test_filter_is_any(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.value)
+ self.assertEqual(self.sample_query.count(), 9)
+
+ # nb. value None is ignored
+ filtered_query = filtr.filter_is_any(self.sample_query, None)
+ self.assertIs(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 9)
+
+
+class TestAlchemyFilter(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': None},
+ ]
+ 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):
+ factory = kwargs.pop('factory', mod.AlchemyFilter)
+ kwargs['model_property'] = model_property
+ return factory(self.request, model_property.key, **kwargs)
+
+ def test_filter_equal(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.value)
+ self.assertEqual(self.sample_query.count(), 9)
+
+ # not filtered for null value
+ filtered_query = filtr.filter_equal(self.sample_query, None)
+ self.assertIs(filtered_query, self.sample_query)
+
+ # nb. by default, *is filtered* by empty string
+ filtered_query = filtr.filter_equal(self.sample_query, '')
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 0)
+
+ # filtered by value
+ filtered_query = filtr.filter_equal(self.sample_query, 'ggg')
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 3)
+
+ def test_filter_not_equal(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_not_equal(self.sample_query, None)
+ self.assertIs(filtered_query, self.sample_query)
+
+ # nb. by default, *is filtered* by empty string
+ filtered_query = filtr.filter_not_equal(self.sample_query, '')
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 9)
+
+ # filtered by value
+ filtered_query = filtr.filter_not_equal(self.sample_query, 'ggg')
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 6)
+
+ def test_filter_is_null(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.value)
+ self.assertEqual(self.sample_query.count(), 9)
+
+ # nb. value None is ignored
+ filtered_query = filtr.filter_is_null(self.sample_query, None)
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 1)
+
+ def test_filter_is_not_null(self):
+ model = self.app.model
+ filtr = self.make_filter(model.Setting.value)
+ self.assertEqual(self.sample_query.count(), 9)
+
+ # nb. value None is ignored
+ filtered_query = filtr.filter_is_not_null(self.sample_query, None)
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 8)
+
+
+class TestStringAlchemyFilter(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):
+ factory = kwargs.pop('factory', mod.StringAlchemyFilter)
+ kwargs['model_property'] = model_property
+ return factory(self.request, model_property.key, **kwargs)
+
+ 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)
+
+ def test_filter_does_not_contain(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_does_not_contain(self.sample_query, None)
+ self.assertIs(filtered_query, self.sample_query)
+ filtered_query = filtr.filter_does_not_contain(self.sample_query, '')
+ self.assertIs(filtered_query, self.sample_query)
+
+ # filtered by value
+ filtered_query = filtr.filter_does_not_contain(self.sample_query, 'ggg')
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 6)
+
+
+class TestBooleanAlchemyFilter(WebTestCase):
+
+ def setUp(self):
+ self.setup_web()
+
+ model = self.app.model
+ self.sample_data = [
+ {'username': 'alice', 'active': True},
+ {'username': 'bob', 'active': True},
+ {'username': 'charlie', 'active': False},
+ ]
+ for user in self.sample_data:
+ user = model.User(**user)
+ self.session.add(user)
+ self.session.commit()
+ self.sample_query = self.session.query(model.User)
+
+ def make_filter(self, model_property, **kwargs):
+ factory = kwargs.pop('factory', mod.BooleanAlchemyFilter)
+ kwargs['model_property'] = model_property
+ return factory(self.request, model_property.key, **kwargs)
+
+ def test_coerce_value(self):
+ model = self.app.model
+ filtr = self.make_filter(model.User.active)
+
+ self.assertIsNone(filtr.coerce_value(None))
+
+ self.assertTrue(filtr.coerce_value(True))
+ self.assertTrue(filtr.coerce_value(1))
+ self.assertTrue(filtr.coerce_value('1'))
+
+ self.assertFalse(filtr.coerce_value(False))
+ self.assertFalse(filtr.coerce_value(0))
+ self.assertFalse(filtr.coerce_value(''))
+
+ def test_filter_is_true(self):
+ model = self.app.model
+ filtr = self.make_filter(model.User.active)
+ self.assertEqual(self.sample_query.count(), 3)
+
+ # nb. value None is ignored
+ filtered_query = filtr.filter_is_true(self.sample_query, None)
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 2)
+
+ def test_filter_is_false(self):
+ model = self.app.model
+ filtr = self.make_filter(model.User.active)
+ self.assertEqual(self.sample_query.count(), 3)
+
+ # nb. value None is ignored
+ filtered_query = filtr.filter_is_false(self.sample_query, None)
+ self.assertIsNot(filtered_query, self.sample_query)
+ self.assertEqual(filtered_query.count(), 1)
+
+
+class TestVerbNotSupported(TestCase):
+
+ def test_basic(self):
+ error = mod.VerbNotSupported('equal')
+ self.assertEqual(str(error), "unknown filter verb not supported: equal")