Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/grids/filters.py: 100%
142 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-23 14:51 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-23 14:51 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Grid Filters
25"""
27import logging
29import sqlalchemy as sa
31from wuttjamaican.util import UNSPECIFIED
34log = logging.getLogger(__name__)
37class VerbNotSupported(Exception):
38 """ """
40 def __init__(self, verb):
41 self.verb = verb
43 def __str__(self):
44 return f"unknown filter verb not supported: {self.verb}"
47class GridFilter:
48 """
49 Filter option for a grid. Represents both the "features" as well
50 as "state" for the filter.
52 :param request: Current :term:`request` object.
54 :param model_property: Property of a model class, representing the
55 column by which to filter. For instance,
56 ``model.Person.full_name``.
58 :param \**kwargs: Any additional kwargs will be set as attributes
59 on the filter instance.
61 Filter instances have the following attributes:
63 .. attribute:: key
65 Unique key for the filter. This often corresponds to a "column
66 name" for the grid, but not always.
68 .. attribute:: label
70 Display label for the filter field.
72 .. attribute:: active
74 Boolean indicating whether the filter is currently active.
76 See also :attr:`verb` and :attr:`value`.
78 .. attribute:: verb
80 Verb for current filter, if :attr:`active` is true.
82 See also :attr:`value`.
84 .. attribute:: value
86 Value for current filter, if :attr:`active` is true.
88 See also :attr:`verb`.
90 .. attribute:: default_active
92 Boolean indicating whether the filter should be active by
93 default, i.e. when first displaying the grid.
95 See also :attr:`default_verb` and :attr:`default_value`.
97 .. attribute:: default_verb
99 Filter verb to use by default. This will be auto-selected when
100 the filter is first activated, or when first displaying the
101 grid if :attr:`default_active` is true.
103 See also :attr:`default_value`.
105 .. attribute:: default_value
107 Filter value to use by default. This will be auto-populated
108 when the filter is first activated, or when first displaying
109 the grid if :attr:`default_active` is true.
111 See also :attr:`default_verb`.
112 """
113 default_verbs = ['equal', 'not_equal']
115 default_verb_labels = {
116 'is_any': "is any",
117 'equal': "equal to",
118 'not_equal': "not equal to",
119 'is_null': "is null",
120 'is_not_null': "is not null",
121 'is_true': "is true",
122 'is_false': "is false",
123 'contains': "contains",
124 'does_not_contain': "does not contain",
125 }
127 valueless_verbs = [
128 'is_any',
129 'is_null',
130 'is_not_null',
131 'is_true',
132 'is_false',
133 ]
135 def __init__(
136 self,
137 request,
138 key,
139 label=None,
140 verbs=None,
141 default_active=False,
142 default_verb=None,
143 default_value=None,
144 **kwargs,
145 ):
146 self.request = request
147 self.key = key
148 self.config = self.request.wutta_config
149 self.app = self.config.get_app()
150 self.label = label or self.app.make_title(self.key)
152 # active
153 self.default_active = default_active
154 self.active = self.default_active
156 # verb
157 if verbs is not None:
158 self.verbs = verbs
159 if default_verb:
160 self.default_verb = default_verb
162 # value
163 self.default_value = default_value
164 self.value = self.default_value
166 self.__dict__.update(kwargs)
168 def __repr__(self):
169 verb = getattr(self, 'verb', None)
170 return (f"{self.__class__.__name__}("
171 f"key='{self.key}', "
172 f"active={self.active}, "
173 f"verb={repr(verb)}, "
174 f"value={repr(self.value)})")
176 def get_verbs(self):
177 """
178 Returns the list of verbs supported by the filter.
179 """
180 verbs = None
182 if hasattr(self, 'verbs'):
183 verbs = self.verbs
185 else:
186 verbs = self.default_verbs
188 if callable(verbs):
189 verbs = verbs()
190 verbs = list(verbs)
192 if self.nullable:
193 if 'is_null' not in verbs:
194 verbs.append('is_null')
195 if 'is_not_null' not in verbs:
196 verbs.append('is_not_null')
198 if 'is_any' not in verbs:
199 verbs.append('is_any')
201 return verbs
203 def get_verb_labels(self):
204 """
205 Returns a dict of all defined verb labels.
206 """
207 # TODO: should traverse hierarchy
208 labels = dict([(verb, verb) for verb in self.get_verbs()])
209 labels.update(self.default_verb_labels)
210 return labels
212 def get_valueless_verbs(self):
213 """
214 Returns a list of verb names which do not need a value.
215 """
216 return self.valueless_verbs
218 def get_default_verb(self):
219 """
220 Returns the default verb for the filter.
221 """
222 verb = None
224 if hasattr(self, 'default_verb'):
225 verb = self.default_verb
227 elif hasattr(self, 'verb'):
228 verb = self.verb
230 if not verb:
231 verbs = self.get_verbs()
232 if verbs:
233 verb = verbs[0]
235 return verb
237 def apply_filter(self, data, verb=None, value=UNSPECIFIED):
238 """
239 Filter the given data set according to a verb/value pair.
241 If verb and/or value are not specified, will use :attr:`verb`
242 and/or :attr:`value` instead.
244 This method does not directly filter the data; rather it
245 delegates (based on ``verb``) to some other method. The
246 latter may choose *not* to filter the data, e.g. if ``value``
247 is empty, in which case this may return the original data set
248 unchanged.
250 :returns: The (possibly) filtered data set.
251 """
252 if verb is None:
253 verb = self.verb
254 if not verb:
255 verb = self.get_default_verb()
256 log.warn("missing verb for '%s' filter, will use default verb: %s",
257 self.key, verb)
259 # only attempt for known verbs
260 if verb not in self.get_verbs():
261 raise VerbNotSupported(verb)
263 # fallback value
264 if value is UNSPECIFIED:
265 value = self.value
267 # locate filter method
268 func = getattr(self, f'filter_{verb}', None)
269 if not func:
270 raise VerbNotSupported(verb)
272 # invoke filter method
273 return func(data, value)
275 def filter_is_any(self, data, value):
276 """
277 This is a no-op which always ignores the value and returns the
278 data as-is.
279 """
280 return data
283class AlchemyFilter(GridFilter):
284 """
285 Filter option for a grid with SQLAlchemy query data.
287 This is a subclass of :class:`GridFilter`. It requires a
288 ``model_property`` to know how to filter the query.
290 :param model_property: Property of a model class, representing the
291 column by which to filter. For instance,
292 ``model.Person.full_name``.
294 :param nullable: Boolean indicating whether the filter should
295 include ``is_null`` and ``is_not_null`` verbs. If not
296 specified, the column will be inspected and use its nullable
297 flag.
298 """
300 def __init__(self, *args, **kwargs):
301 nullable = kwargs.pop('nullable', None)
302 super().__init__(*args, **kwargs)
304 self.nullable = nullable
305 if self.nullable is None:
306 columns = self.model_property.prop.columns
307 if len(columns) == 1:
308 self.nullable = columns[0].nullable
310 def coerce_value(self, value):
311 """
312 Coerce the given value to the correct type/format for use with
313 the filter.
315 Default logic returns value as-is; subclass may override.
316 """
317 return value
319 def filter_equal(self, query, value):
320 """
321 Filter data with an equal (``=``) condition.
322 """
323 value = self.coerce_value(value)
324 if value is None:
325 return query
327 return query.filter(self.model_property == value)
329 def filter_not_equal(self, query, value):
330 """
331 Filter data with a not equal (``!=``) condition.
332 """
333 value = self.coerce_value(value)
334 if value is None:
335 return query
337 # sql probably excludes null values from results, but user
338 # probably does not expect that, so explicitly include them.
339 return query.filter(sa.or_(
340 self.model_property == None,
341 self.model_property != value,
342 ))
344 def filter_is_null(self, query, value):
345 """
346 Filter data with an ``IS NULL`` query. The value is ignored.
347 """
348 return query.filter(self.model_property == None)
350 def filter_is_not_null(self, query, value):
351 """
352 Filter data with an ``IS NOT NULL`` query. The value is
353 ignored.
354 """
355 return query.filter(self.model_property != None)
358class StringAlchemyFilter(AlchemyFilter):
359 """
360 SQLAlchemy filter option for a text data column.
362 Subclass of :class:`AlchemyFilter`.
363 """
364 default_verbs = ['contains', 'does_not_contain',
365 'equal', 'not_equal']
367 def coerce_value(self, value):
368 """ """
369 if value is not None:
370 value = str(value)
371 if value:
372 return value
374 def filter_contains(self, query, value):
375 """
376 Filter data with an ``ILIKE`` condition.
377 """
378 value = self.coerce_value(value)
379 if not value:
380 return query
382 criteria = []
383 for val in value.split():
384 val = val.replace('_', r'\_')
385 val = f'%{val}%'
386 criteria.append(self.model_property.ilike(val))
388 return query.filter(sa.and_(*criteria))
390 def filter_does_not_contain(self, query, value):
391 """
392 Filter data with a ``NOT ILIKE`` condition.
393 """
394 value = self.coerce_value(value)
395 if not value:
396 return query
398 criteria = []
399 for val in value.split():
400 val = val.replace('_', r'\_')
401 val = f'%{val}%'
402 criteria.append(~self.model_property.ilike(val))
404 # sql probably excludes null values from results, but user
405 # probably does not expect that, so explicitly include them.
406 return query.filter(sa.or_(
407 self.model_property == None,
408 sa.and_(*criteria)))
411class BooleanAlchemyFilter(AlchemyFilter):
412 """
413 SQLAlchemy filter option for a boolean data column.
415 Subclass of :class:`AlchemyFilter`.
416 """
417 default_verbs = ['is_true', 'is_false']
419 def coerce_value(self, value):
420 """ """
421 if value is not None:
422 return bool(value)
424 def filter_is_true(self, query, value):
425 """
426 Filter data with an "is true" condition. The value is
427 ignored.
428 """
429 return query.filter(self.model_property == True)
431 def filter_is_false(self, query, value):
432 """
433 Filter data with an "is false" condition. The value is
434 ignored.
435 """
436 return query.filter(self.model_property == False)
439default_sqlalchemy_filters = {
440 None: AlchemyFilter,
441 sa.String: StringAlchemyFilter,
442 sa.Text: StringAlchemyFilter,
443 sa.Boolean: BooleanAlchemyFilter,
444}