Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / grids / filters.py: 100%
226 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2025 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 datetime
28import logging
29from collections import OrderedDict
31try:
32 from enum import EnumType
33except ImportError: # pragma: no cover
34 # nb. python <= 3.10
35 from enum import EnumMeta as EnumType
37import sqlalchemy as sa
39from wuttjamaican.util import UNSPECIFIED
42log = logging.getLogger(__name__)
45class VerbNotSupported(Exception): # pylint: disable=empty-docstring
46 """ """
48 def __init__(self, verb):
49 self.verb = verb
51 def __str__(self):
52 return f"unknown filter verb not supported: {self.verb}"
55class GridFilter: # pylint: disable=too-many-instance-attributes
56 """
57 Filter option for a grid. Represents both the "features" as well
58 as "state" for the filter.
60 :param request: Current :term:`request` object.
62 :param nullable: Boolean indicating whether the filter should
63 include ``is_null`` and ``is_not_null`` verbs. If not
64 specified, the column will be inspected (if possible) and use
65 its nullable flag.
67 :param \\**kwargs: Any additional kwargs will be set as attributes
68 on the filter instance.
70 Filter instances have the following attributes:
72 .. attribute:: key
74 Unique key for the filter. This often corresponds to a "column
75 name" for the grid, but not always.
77 .. attribute:: label
79 Display label for the filter field.
81 .. attribute:: data_type
83 Simplistic "data type" which the filter supports. So far this
84 will be one of:
86 * ``'string'``
87 * ``'date'``
88 * ``'choice'``
90 Note that this mainly applies to the "value input" used by the
91 filter. There is no data type for boolean since it does not
92 need a value input; the verb is enough.
94 .. attribute:: active
96 Boolean indicating whether the filter is currently active.
98 See also :attr:`verb` and :attr:`value`.
100 .. attribute:: verb
102 Verb for current filter, if :attr:`active` is true.
104 See also :attr:`value`.
106 .. attribute:: choices
108 OrderedDict of possible values for the filter.
110 This is safe to read from, but use :meth:`set_choices()` to
111 update it.
113 .. attribute:: value
115 Value for current filter, if :attr:`active` is true.
117 See also :attr:`verb`.
119 .. attribute:: default_active
121 Boolean indicating whether the filter should be active by
122 default, i.e. when first displaying the grid.
124 See also :attr:`default_verb` and :attr:`default_value`.
126 .. attribute:: default_verb
128 Filter verb to use by default. This will be auto-selected when
129 the filter is first activated, or when first displaying the
130 grid if :attr:`default_active` is true.
132 See also :attr:`default_value`.
134 .. attribute:: default_value
136 Filter value to use by default. This will be auto-populated
137 when the filter is first activated, or when first displaying
138 the grid if :attr:`default_active` is true.
140 See also :attr:`default_verb`.
141 """
143 data_type = "string"
144 default_verbs = ["equal", "not_equal"]
146 default_verb_labels = {
147 "is_any": "is any",
148 "equal": "equal to",
149 "not_equal": "not equal to",
150 "greater_than": "greater than",
151 "greater_equal": "greater than or equal to",
152 "less_than": "less than",
153 "less_equal": "less than or equal to",
154 # 'between': "between",
155 "is_true": "is true",
156 "is_false": "is false",
157 "is_false_null": "is false or null",
158 "is_null": "is null",
159 "is_not_null": "is not null",
160 "contains": "contains",
161 "does_not_contain": "does not contain",
162 }
164 valueless_verbs = [
165 "is_any",
166 "is_true",
167 "is_false",
168 "is_false_null",
169 "is_null",
170 "is_not_null",
171 ]
173 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
174 self,
175 request,
176 key,
177 label=None,
178 verbs=None,
179 choices=None,
180 nullable=None,
181 default_active=False,
182 default_verb=None,
183 default_value=None,
184 **kwargs,
185 ):
186 self.request = request
187 self.key = key
188 self.config = self.request.wutta_config
189 self.app = self.config.get_app()
190 self.label = label or self.app.make_title(self.key)
192 # active
193 self.default_active = default_active
194 self.active = self.default_active
196 # verb
197 if verbs is not None:
198 self.verbs = verbs
199 if default_verb:
200 self.default_verb = default_verb
201 self.verb = None # active verb is set later
203 # choices
204 self.set_choices(choices or {})
206 # nullable
207 self.nullable = nullable
209 # value
210 self.default_value = default_value
211 self.value = self.default_value
213 self.__dict__.update(kwargs)
215 def __repr__(self):
216 verb = getattr(self, "verb", None)
217 return (
218 f"{self.__class__.__name__}("
219 f"key='{self.key}', "
220 f"active={self.active}, "
221 f"verb={repr(verb)}, "
222 f"value={repr(self.value)})"
223 )
225 def get_verbs(self):
226 """
227 Returns the list of verbs supported by the filter.
228 """
229 verbs = None
231 if hasattr(self, "verbs"):
232 verbs = self.verbs
234 else:
235 verbs = self.default_verbs
237 if callable(verbs):
238 verbs = verbs()
239 verbs = list(verbs)
241 if self.nullable:
242 if "is_null" not in verbs:
243 verbs.append("is_null")
244 if "is_not_null" not in verbs:
245 verbs.append("is_not_null")
247 if "is_any" not in verbs:
248 verbs.append("is_any")
250 return verbs
252 def get_verb_labels(self):
253 """
254 Returns a dict of all defined verb labels.
255 """
256 # TODO: should traverse hierarchy
257 labels = {verb: verb for verb in self.get_verbs()}
258 labels.update(self.default_verb_labels)
259 return labels
261 def get_valueless_verbs(self):
262 """
263 Returns a list of verb names which do not need a value.
264 """
265 return self.valueless_verbs
267 def get_default_verb(self):
268 """
269 Returns the default verb for the filter.
270 """
271 verb = None
273 if hasattr(self, "default_verb"):
274 verb = self.default_verb
276 elif hasattr(self, "verb"):
277 verb = self.verb
279 if not verb:
280 verbs = self.get_verbs()
281 if verbs:
282 verb = verbs[0]
284 return verb
286 def set_choices(self, choices):
287 """
288 Set the value choices for the filter.
290 If ``choices`` is non-empty, it is passed to
291 :meth:`normalize_choices()` and the result is assigned to
292 :attr:`choices`. Also, the :attr:`data_type` is set to
293 ``'choice'`` so the UI will present the value input as a
294 dropdown.
296 But if ``choices`` is empty, :attr:`choices` is set to an
297 empty dict, and :attr:`data_type` is set (back) to
298 ``'string'``.
300 :param choices: Collection of "choices" or ``None``.
301 """
302 if choices:
303 self.choices = self.normalize_choices(choices)
304 self.data_type = "choice"
305 else:
306 self.choices = {}
307 self.data_type = "string"
309 def normalize_choices(self, choices):
310 """
311 Normalize a collection of "choices" to standard ``OrderedDict``.
313 This is called automatically by :meth:`set_choices()`.
315 :param choices: A collection of "choices" in one of the following
316 formats:
318 * :class:`python:enum.Enum` class
319 * simple list, each value of which should be a string,
320 which is assumed to be able to serve as both key and
321 value (ordering of choices will be preserved)
322 * simple dict, keys and values of which will define the
323 choices (note that the final choices will be sorted by
324 key!)
325 * OrderedDict, keys and values of which will define the
326 choices (ordering of choices will be preserved)
328 :rtype: :class:`python:collections.OrderedDict`
329 """
330 normalized = choices
332 if isinstance(choices, EnumType):
333 normalized = OrderedDict(
334 [(member.name, member.value) for member in choices]
335 )
337 elif isinstance(choices, OrderedDict):
338 normalized = choices
340 elif isinstance(choices, dict):
341 normalized = OrderedDict([(key, choices[key]) for key in sorted(choices)])
343 elif isinstance(choices, list):
344 normalized = OrderedDict([(key, key) for key in choices])
346 return normalized
348 def apply_filter(self, data, verb=None, value=UNSPECIFIED):
349 """
350 Filter the given data set according to a verb/value pair.
352 If verb and/or value are not specified, will use :attr:`verb`
353 and/or :attr:`value` instead.
355 This method does not directly filter the data; rather it
356 delegates (based on ``verb``) to some other method. The
357 latter may choose *not* to filter the data, e.g. if ``value``
358 is empty, in which case this may return the original data set
359 unchanged.
361 :returns: The (possibly) filtered data set.
362 """
363 if verb is None:
364 verb = self.verb
365 if not verb:
366 verb = self.get_default_verb()
367 log.warning(
368 "missing verb for '%s' filter, will use default verb: %s",
369 self.key,
370 verb,
371 )
373 # only attempt for known verbs
374 if verb not in self.get_verbs():
375 raise VerbNotSupported(verb)
377 # fallback value
378 if value is UNSPECIFIED:
379 value = self.value
381 # locate filter method
382 func = getattr(self, f"filter_{verb}", None)
383 if not func:
384 raise VerbNotSupported(verb)
386 # invoke filter method
387 return func(data, value) # pylint: disable=not-callable
389 def filter_is_any(self, data, value): # pylint: disable=unused-argument
390 """
391 This is a no-op which always ignores the value and returns the
392 data as-is.
393 """
394 return data
397class AlchemyFilter(GridFilter):
398 """
399 Filter option for a grid with SQLAlchemy query data.
401 This is a subclass of :class:`GridFilter`. It requires a
402 ``model_property`` to know how to filter the query.
404 :param model_property: Property of a model class, representing the
405 column by which to filter. For instance,
406 ``model.Person.full_name``.
407 """
409 def __init__(self, *args, **kwargs):
410 self.model_property = kwargs.pop("model_property")
411 super().__init__(*args, **kwargs)
413 if self.nullable is None:
414 columns = self.model_property.prop.columns
415 if len(columns) == 1:
416 self.nullable = columns[0].nullable
418 def coerce_value(self, value):
419 """
420 Coerce the given value to the correct type/format for use with
421 the filter.
423 Default logic returns value as-is; subclass may override.
424 """
425 return value
427 def filter_equal(self, query, value):
428 """
429 Filter data with an equal (``=``) condition.
430 """
431 value = self.coerce_value(value)
432 if value is None:
433 return query
435 return query.filter(self.model_property == value)
437 def filter_not_equal(self, query, value):
438 """
439 Filter data with a not equal (``!=``) condition.
440 """
441 value = self.coerce_value(value)
442 if value is None:
443 return query
445 # sql probably excludes null values from results, but user
446 # probably does not expect that, so explicitly include them.
447 return query.filter(
448 sa.or_(
449 self.model_property == None, # pylint: disable=singleton-comparison
450 self.model_property != value,
451 )
452 )
454 def filter_greater_than(self, query, value):
455 """
456 Filter data with a greater than (``>``) condition.
457 """
458 value = self.coerce_value(value)
459 if value is None:
460 return query
461 return query.filter(self.model_property > value)
463 def filter_greater_equal(self, query, value):
464 """
465 Filter data with a greater than or equal (``>=``) condition.
466 """
467 value = self.coerce_value(value)
468 if value is None:
469 return query
470 return query.filter(self.model_property >= value)
472 def filter_less_than(self, query, value):
473 """
474 Filter data with a less than (``<``) condition.
475 """
476 value = self.coerce_value(value)
477 if value is None:
478 return query
479 return query.filter(self.model_property < value)
481 def filter_less_equal(self, query, value):
482 """
483 Filter data with a less than or equal (``<=``) condition.
484 """
485 value = self.coerce_value(value)
486 if value is None:
487 return query
488 return query.filter(self.model_property <= value)
490 def filter_is_null(self, query, value): # pylint: disable=unused-argument
491 """
492 Filter data with an ``IS NULL`` query. The value is ignored.
493 """
494 return query.filter(
495 self.model_property == None # pylint: disable=singleton-comparison
496 )
498 def filter_is_not_null(self, query, value): # pylint: disable=unused-argument
499 """
500 Filter data with an ``IS NOT NULL`` query. The value is
501 ignored.
502 """
503 return query.filter(
504 self.model_property != None # pylint: disable=singleton-comparison
505 )
508class StringAlchemyFilter(AlchemyFilter):
509 """
510 SQLAlchemy filter option for a text data column.
512 Subclass of :class:`AlchemyFilter`.
513 """
515 default_verbs = ["contains", "does_not_contain", "equal", "not_equal"]
517 def coerce_value(self, value): # pylint: disable=empty-docstring
518 """ """
519 if value is not None:
520 value = str(value)
521 if value:
522 return value
523 return None
525 def filter_contains(self, query, value):
526 """
527 Filter data with an ``ILIKE`` condition.
528 """
529 value = self.coerce_value(value)
530 if not value:
531 return query
533 criteria = []
534 for val in value.split():
535 val = val.replace("_", r"\_")
536 val = f"%{val}%"
537 criteria.append(self.model_property.ilike(val))
539 return query.filter(sa.and_(*criteria))
541 def filter_does_not_contain(self, query, value):
542 """
543 Filter data with a ``NOT ILIKE`` condition.
544 """
545 value = self.coerce_value(value)
546 if not value:
547 return query
549 criteria = []
550 for val in value.split():
551 val = val.replace("_", r"\_")
552 val = f"%{val}%"
553 criteria.append(~self.model_property.ilike(val))
555 # sql probably excludes null values from results, but user
556 # probably does not expect that, so explicitly include them.
557 return query.filter(
558 sa.or_(
559 self.model_property == None, # pylint: disable=singleton-comparison
560 sa.and_(*criteria),
561 )
562 )
565class NumericAlchemyFilter(AlchemyFilter):
566 """
567 SQLAlchemy filter option for a numeric data column.
569 Subclass of :class:`AlchemyFilter`.
570 """
572 default_verbs = [
573 "equal",
574 "not_equal",
575 "greater_than",
576 "greater_equal",
577 "less_than",
578 "less_equal",
579 ]
582class IntegerAlchemyFilter(NumericAlchemyFilter):
583 """
584 SQLAlchemy filter option for an integer data column.
586 Subclass of :class:`NumericAlchemyFilter`.
587 """
589 def coerce_value(self, value): # pylint: disable=empty-docstring
590 """ """
591 if value:
592 try:
593 return int(value)
594 except Exception: # pylint: disable=broad-exception-caught
595 pass
596 return None
599class BooleanAlchemyFilter(AlchemyFilter):
600 """
601 SQLAlchemy filter option for a boolean data column.
603 Subclass of :class:`AlchemyFilter`.
604 """
606 default_verbs = ["is_true", "is_false"]
608 def get_verbs(self): # pylint: disable=empty-docstring
609 """ """
611 # get basic verbs from caller, or default list
612 verbs = getattr(self, "verbs", self.default_verbs)
613 if callable(verbs):
614 verbs = verbs()
615 verbs = list(verbs)
617 # add some more if column is nullable
618 if self.nullable:
619 for verb in ("is_false_null", "is_null", "is_not_null"):
620 if verb not in verbs:
621 verbs.append(verb)
623 # add wildcard
624 if "is_any" not in verbs:
625 verbs.append("is_any")
627 return verbs
629 def coerce_value(self, value): # pylint: disable=empty-docstring
630 """ """
631 if value is not None:
632 return bool(value)
633 return None
635 def filter_is_true(self, query, value): # pylint: disable=unused-argument
636 """
637 Filter data with an "is true" condition. The value is
638 ignored.
639 """
640 return query.filter(
641 self.model_property == True # pylint: disable=singleton-comparison
642 )
644 def filter_is_false(self, query, value): # pylint: disable=unused-argument
645 """
646 Filter data with an "is false" condition. The value is
647 ignored.
648 """
649 return query.filter(
650 self.model_property == False # pylint: disable=singleton-comparison
651 )
653 def filter_is_false_null(self, query, value): # pylint: disable=unused-argument
654 """
655 Filter data with "is false or null" condition. The value is
656 ignored.
657 """
658 return query.filter(
659 sa.or_(
660 self.model_property == False, # pylint: disable=singleton-comparison
661 self.model_property == None, # pylint: disable=singleton-comparison
662 )
663 )
666class DateAlchemyFilter(AlchemyFilter):
667 """
668 SQLAlchemy filter option for a
669 :class:`sqlalchemy:sqlalchemy.types.Date` column.
671 Subclass of :class:`AlchemyFilter`.
672 """
674 data_type = "date"
675 default_verbs = [
676 "equal",
677 "not_equal",
678 "greater_than",
679 "greater_equal",
680 "less_than",
681 "less_equal",
682 # 'between',
683 ]
685 default_verb_labels = {
686 "equal": "on",
687 "not_equal": "not on",
688 "greater_than": "after",
689 "greater_equal": "on or after",
690 "less_than": "before",
691 "less_equal": "on or before",
692 # 'between': "between",
693 }
695 def coerce_value(self, value): # pylint: disable=empty-docstring
696 """ """
697 if value:
698 if isinstance(value, datetime.date):
699 return value
701 try:
702 dt = datetime.datetime.strptime(value, "%Y-%m-%d")
703 except ValueError:
704 log.warning("invalid date value: %s", value)
705 else:
706 return dt.date()
708 return None
711default_sqlalchemy_filters = {
712 None: AlchemyFilter,
713 sa.String: StringAlchemyFilter,
714 sa.Text: StringAlchemyFilter,
715 sa.Numeric: NumericAlchemyFilter,
716 sa.Integer: IntegerAlchemyFilter,
717 sa.Boolean: BooleanAlchemyFilter,
718 sa.Date: DateAlchemyFilter,
719}