From f68fe26ada00ae15b7508123d846b58eeb420f2e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Dec 2024 18:43:40 -0600 Subject: [PATCH] fix: add "is false or null" grid filter, for nullable bool columns --- src/wuttaweb/grids/filters.py | 39 ++++++++++++++++++++++++--- tests/grids/test_filters.py | 50 ++++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/wuttaweb/grids/filters.py b/src/wuttaweb/grids/filters.py index 0489c22..1cbdb89 100644 --- a/src/wuttaweb/grids/filters.py +++ b/src/wuttaweb/grids/filters.py @@ -116,20 +116,22 @@ class GridFilter: '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", + 'is_false_null': "is false or null", + 'is_null': "is null", + 'is_not_null': "is not null", 'contains': "contains", 'does_not_contain': "does not contain", } valueless_verbs = [ 'is_any', - 'is_null', - 'is_not_null', 'is_true', 'is_false', + 'is_false_null', + 'is_null', + 'is_not_null', ] def __init__( @@ -416,6 +418,27 @@ class BooleanAlchemyFilter(AlchemyFilter): """ default_verbs = ['is_true', 'is_false'] + def get_verbs(self): + """ """ + + # get basic verbs from caller, or default list + verbs = getattr(self, 'verbs', self.default_verbs) + if callable(verbs): + verbs = verbs() + verbs = list(verbs) + + # add some more if column is nullable + if self.nullable: + for verb in ('is_false_null', 'is_null', 'is_not_null'): + if verb not in verbs: + verbs.append(verb) + + # add wildcard + if 'is_any' not in verbs: + verbs.append('is_any') + + return verbs + def coerce_value(self, value): """ """ if value is not None: @@ -435,6 +458,14 @@ class BooleanAlchemyFilter(AlchemyFilter): """ return query.filter(self.model_property == False) + def filter_is_false_null(self, query, value): + """ + Filter data with "is false or null" condition. The value is + ignored. + """ + return query.filter(sa.or_(self.model_property == False, + self.model_property == None)) + default_sqlalchemy_filters = { None: AlchemyFilter, diff --git a/tests/grids/test_filters.py b/tests/grids/test_filters.py index 2d8afdc..66bf713 100644 --- a/tests/grids/test_filters.py +++ b/tests/grids/test_filters.py @@ -328,9 +328,15 @@ class TestBooleanAlchemyFilter(WebTestCase): model = self.app.model self.sample_data = [ - {'username': 'alice', 'active': True}, - {'username': 'bob', 'active': True}, - {'username': 'charlie', 'active': False}, + {'username': 'alice', + 'prevent_edit': False, + 'active': True}, + {'username': 'bob', + 'prevent_edit': True, + 'active': True}, + {'username': 'charlie', + 'active': False, + 'prevent_edit': None}, ] for user in self.sample_data: user = model.User(**user) @@ -343,6 +349,34 @@ class TestBooleanAlchemyFilter(WebTestCase): kwargs['model_property'] = model_property return factory(self.request, model_property.key, **kwargs) + def test_get_verbs(self): + model = self.app.model + + # bool field, not nullable + filtr = self.make_filter(model.User.active, + factory=mod.BooleanAlchemyFilter, + nullable=False) + self.assertFalse(hasattr(filtr, 'verbs')) + self.assertEqual(filtr.default_verbs, ['is_true', 'is_false']) + + # by default, returns default verbs (plus 'is_any') + self.assertEqual(filtr.get_verbs(), ['is_true', 'is_false', 'is_any']) + + # default verbs can be a callable + filtr.default_verbs = lambda: ['foo', 'bar'] + self.assertEqual(filtr.get_verbs(), ['foo', 'bar', 'is_any']) + + # bool field, *nullable* + filtr = self.make_filter(model.User.active, + factory=mod.BooleanAlchemyFilter, + nullable=True) + self.assertFalse(hasattr(filtr, 'verbs')) + self.assertEqual(filtr.default_verbs, ['is_true', 'is_false']) + + # effective verbs also include is_false_null + self.assertEqual(filtr.get_verbs(), ['is_true', 'is_false', 'is_false_null', + 'is_null', 'is_not_null', 'is_any']) + def test_coerce_value(self): model = self.app.model filtr = self.make_filter(model.User.active) @@ -377,6 +411,16 @@ class TestBooleanAlchemyFilter(WebTestCase): self.assertIsNot(filtered_query, self.sample_query) self.assertEqual(filtered_query.count(), 1) + def test_filter_is_false_null(self): + model = self.app.model + filtr = self.make_filter(model.User.prevent_edit) + self.assertEqual(self.sample_query.count(), 3) + + # nb. only one account is marked with "prevent edit" + filtered_query = filtr.filter_is_false_null(self.sample_query, None) + self.assertIsNot(filtered_query, self.sample_query) + self.assertEqual(filtered_query.count(), 2) + class TestVerbNotSupported(TestCase):