3
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

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

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"
class="filter-verb"
@input="focusValue()"
:size="isSmall ? 'is-small' : null">
<option v-for="verb in filter.verbs"
:key="verb"
:value="verb">
{{ verb }}
{{ filter.verb_labels[verb] || verb }}
</option>
</b-select>
<wutta-filter-value v-model="filter.value"
ref="filterValue"
v-show="valuedVerb()"
:is-small="isSmall" />
</div>
@ -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
},
}
}