From f6fb6957e327c8f77b69755936bc83a3c322a929 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 23 Aug 2024 12:10:51 -0500 Subject: [PATCH] feat: improve grid filter API a bit, support string/bool filters --- docs/api/wuttaweb/grids.filters.rst | 6 + docs/api/wuttaweb/index.rst | 1 + src/wuttaweb/grids/base.py | 207 ++------- src/wuttaweb/grids/filters.py | 444 +++++++++++++++++++ src/wuttaweb/templates/wutta-components.mako | 25 +- tests/grids/test_base.py | 122 ++--- tests/grids/test_filters.py | 385 ++++++++++++++++ 7 files changed, 919 insertions(+), 271 deletions(-) create mode 100644 docs/api/wuttaweb/grids.filters.rst create mode 100644 src/wuttaweb/grids/filters.py create mode 100644 tests/grids/test_filters.py 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")