2
0
Fork 0

feat: improve grid filter API a bit, support string/bool filters

This commit is contained in:
Lance Edgar 2024-08-23 12:10:51 -05:00
parent 4525f91c21
commit f6fb6957e3
7 changed files with 919 additions and 271 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.grids.filters``
==========================
.. automodule:: wuttaweb.grids.filters
:members:

View file

@ -16,6 +16,7 @@
forms.widgets forms.widgets
grids grids
grids.base grids.base
grids.filters
handler handler
helpers helpers
menus menus

View file

@ -40,6 +40,7 @@ from webhelpers2.html import HTML
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttjamaican.util import UNSPECIFIED from wuttjamaican.util import UNSPECIFIED
from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -1034,8 +1035,9 @@ class Grid:
def make_filter(self, columninfo, **kwargs): def make_filter(self, columninfo, **kwargs):
""" """
Create and return a :class:`GridFilter` instance suitable for Create and return a
use on the given column. :class:`~wuttaweb.grids.filters.GridFilter` instance suitable
for use on the given column.
Code usually does not need to call this directly. See also Code usually does not need to call this directly. See also
:meth:`set_filter()`, which calls this method automatically. :meth:`set_filter()`, which calls this method automatically.
@ -1043,24 +1045,34 @@ class Grid:
:param columninfo: Can be either a model property (see below), :param columninfo: Can be either a model property (see below),
or a column name. or a column name.
:returns: A :class:`GridFilter` instance. :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
instance.
""" """
# model_property is required
model_property = None model_property = None
if isinstance(columninfo, str): if kwargs.get('model_property'):
model_property = kwargs['model_property']
elif isinstance(columninfo, str):
key = columninfo key = columninfo
if self.model_class: if self.model_class:
try: model_property = getattr(self.model_class, key, None)
mapper = sa.inspect(self.model_class)
except sa.exc.NoInspectionAvailable:
pass
else:
model_property = mapper.get_property(key)
if not model_property: if not model_property:
raise ValueError(f"cannot locate model property for key: {key}") raise ValueError(f"cannot locate model property for key: {key}")
else: else:
model_property = columninfo 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): def set_filter(self, key, filterinfo=None, **kwargs):
""" """
@ -1228,7 +1240,7 @@ class Grid:
settings[f'filter.{filtr.key}.active'] = defaults.get('active', settings[f'filter.{filtr.key}.active'] = defaults.get('active',
filtr.default_active) filtr.default_active)
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', 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', settings[f'filter.{filtr.key}.value'] = defaults.get('value',
filtr.default_value) filtr.default_value)
if self.sortable: if self.sortable:
@ -1291,7 +1303,7 @@ class Grid:
if self.filterable: if self.filterable:
for filtr in self.filters.values(): for filtr in self.filters.values():
filtr.active = settings[f'filter.{filtr.key}.active'] 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'] filtr.value = settings[f'filter.{filtr.key}.value']
# sorting # sorting
@ -1531,8 +1543,8 @@ class Grid:
""" """
Returns the list of currently active filters. Returns the list of currently active filters.
This inspects each :class:`GridFilter` in :attr:`filters` and This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
only returns the ones marked active. in :attr:`filters` and only returns the ones marked active.
""" """
return [filtr for filtr in self.filters.values() return [filtr for filtr in self.filters.values()
if filtr.active] if filtr.active]
@ -1848,7 +1860,9 @@ class Grid:
'key': filtr.key, 'key': filtr.key,
'active': filtr.active, 'active': filtr.active,
'visible': 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, 'verb': filtr.verb,
'value': filtr.value, 'value': filtr.value,
'label': filtr.label, 'label': filtr.label,
@ -2081,164 +2095,3 @@ class GridAction:
return self.url(obj, i) return self.url(obj, i)
return self.url 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}"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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,
}

View file

@ -90,16 +90,18 @@
<b-select v-model="filter.verb" <b-select v-model="filter.verb"
class="filter-verb" class="filter-verb"
@input="focusValue()"
:size="isSmall ? 'is-small' : null"> :size="isSmall ? 'is-small' : null">
<option v-for="verb in filter.verbs" <option v-for="verb in filter.verbs"
:key="verb" :key="verb"
:value="verb"> :value="verb">
{{ verb }} {{ filter.verb_labels[verb] || verb }}
</option> </option>
</b-select> </b-select>
<wutta-filter-value v-model="filter.value" <wutta-filter-value v-model="filter.value"
ref="filterValue" ref="filterValue"
v-show="valuedVerb()"
:is-small="isSmall" /> :is-small="isSmall" />
</div> </div>
@ -116,9 +118,26 @@
methods: { methods: {
focusValue: function() { focusValue() {
this.$refs.filterValue.focus() 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
},
} }
} }

View file

@ -3,6 +3,7 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from paginate import Page from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage from paginate_sqlalchemy import SqlalchemyOrmPage
@ -10,6 +11,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList from wuttaweb.util import FieldList
from wuttaweb.forms import Form from wuttaweb.forms import Form
from tests.util import WebTestCase from tests.util import WebTestCase
@ -921,20 +923,38 @@ class TestGrid(WebTestCase):
def test_make_filter(self): def test_make_filter(self):
model = self.app.model model = self.app.model
# basic # arg is column name
grid = self.make_grid(model_class=model.Setting) grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter('name') filtr = grid.make_filter('name')
self.assertIsInstance(filtr, mod.GridFilter) self.assertIsInstance(filtr, StringAlchemyFilter)
# property # arg is column name, but model class is invalid
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter(model.Setting.name)
self.assertIsInstance(filtr, mod.GridFilter)
# invalid model class
grid = self.make_grid(model_class=42) grid = self.make_grid(model_class=42)
self.assertRaises(ValueError, grid.make_filter, 'name') 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): def test_set_filter(self):
model = self.app.model model = self.app.model
@ -1049,6 +1069,9 @@ class TestGrid(WebTestCase):
sample_query = self.session.query(model.Setting) sample_query = self.session.query(model.Setting)
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) 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 # not filtered by default
grid.load_settings() grid.load_settings()
@ -1421,86 +1444,3 @@ class TestGridAction(TestCase):
action = self.make_action('blarg', url=lambda o, i: '/yeehaw') action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
url = action.get_url(obj) url = action.get_url(obj)
self.assertEqual(url, '/yeehaw') 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")

385
tests/grids/test_filters.py Normal file
View file

@ -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")