Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/grids/filters.py: 100%
282 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 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, get_class_hierarchy
41log = logging.getLogger(__name__)
44class VerbNotSupported(Exception): # pylint: disable=empty-docstring
45 """ """
47 def __init__(self, verb):
48 self.verb = verb
50 def __str__(self):
51 return f"unknown filter verb not supported: {self.verb}"
54class GridFilter: # pylint: disable=too-many-instance-attributes
55 """
56 Filter option for a grid. Represents both the "features" as well
57 as "state" for the filter.
59 :param request: Current :term:`request` object.
61 :param nullable: Boolean indicating whether the filter should
62 include ``is_null`` and ``is_not_null`` verbs. If not
63 specified, the column will be inspected (if possible) and use
64 its nullable flag.
66 :param \\**kwargs: Any additional kwargs will be set as attributes
67 on the filter instance.
69 Filter instances have the following attributes:
71 .. attribute:: key
73 Unique key for the filter. This often corresponds to a "column
74 name" for the grid, but not always.
76 .. attribute:: label
78 Display label for the filter field.
80 .. attribute:: data_type
82 Simplistic "data type" which the filter supports. So far this
83 will be one of:
85 * ``'string'``
86 * ``'date'``
87 * ``'choice'``
89 Note that this mainly applies to the "value input" used by the
90 filter. There is no data type for boolean since it does not
91 need a value input; the verb is enough.
93 .. attribute:: active
95 Boolean indicating whether the filter is currently active.
97 See also :attr:`verb` and :attr:`value`.
99 .. attribute:: verb
101 Verb for current filter, if :attr:`active` is true.
103 See also :attr:`value`.
105 .. attribute:: choices
107 OrderedDict of possible values for the filter.
109 This is safe to read from, but use :meth:`set_choices()` to
110 update it.
112 .. attribute:: value
114 Value for current filter, if :attr:`active` is true.
116 See also :attr:`verb`.
118 .. attribute:: default_active
120 Boolean indicating whether the filter should be active by
121 default, i.e. when first displaying the grid.
123 See also :attr:`default_verb` and :attr:`default_value`.
125 .. attribute:: default_verb
127 Filter verb to use by default. This will be auto-selected when
128 the filter is first activated, or when first displaying the
129 grid if :attr:`default_active` is true.
131 See also :attr:`default_value`.
133 .. attribute:: default_value
135 Filter value to use by default. This will be auto-populated
136 when the filter is first activated, or when first displaying
137 the grid if :attr:`default_active` is true.
139 See also :attr:`default_verb`.
141 .. attribute:: default_verb_labels
143 Dict of human-friendly verb labels, keyed by internal verb
144 names. Subclass can define additional verb labels, and/or
145 override those defined on the base class. These labels are
146 "cumulative" so a subclass need only define the ones needing
147 customization.
149 See also :meth:`get_verb_labels()`.
150 """
152 data_type = "string"
153 default_verbs = ["equal", "not_equal"]
155 default_verb_labels = {
156 "is_any": "is any",
157 "equal": "equal to",
158 "not_equal": "not equal to",
159 "greater_than": "greater than",
160 "greater_equal": "greater than or equal to",
161 "less_than": "less than",
162 "less_equal": "less than or equal to",
163 # 'between': "between",
164 "is_true": "is true",
165 "is_false": "is false",
166 "is_false_null": "is false or null",
167 "is_null": "is null",
168 "is_not_null": "is not null",
169 "contains": "contains",
170 "does_not_contain": "does not contain",
171 }
173 valueless_verbs = [
174 "is_any",
175 "is_true",
176 "is_false",
177 "is_false_null",
178 "is_null",
179 "is_not_null",
180 ]
182 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
183 self,
184 request,
185 key,
186 label=None,
187 verbs=None,
188 choices=None,
189 nullable=None,
190 default_active=False,
191 default_verb=None,
192 default_value=None,
193 **kwargs,
194 ):
195 self.request = request
196 self.key = key
197 self.config = self.request.wutta_config
198 self.app = self.config.get_app()
199 self.label = label or self.app.make_title(self.key)
201 # remember original data type in case we need to revert,
202 # e.g. after changing it to 'choices' and back again
203 self.original_data_type = self.data_type
205 # active
206 self.default_active = default_active
207 self.active = self.default_active
209 # verb
210 if verbs is not None:
211 self.verbs = verbs
212 if default_verb:
213 self.default_verb = default_verb
214 self.verb = None # active verb is set later
216 # choices
217 self.set_choices(choices)
219 # nullable
220 self.nullable = nullable
222 # value
223 self.default_value = default_value
224 self.value = self.default_value
226 self.__dict__.update(kwargs)
228 def __repr__(self):
229 verb = getattr(self, "verb", None)
230 return (
231 f"{self.__class__.__name__}("
232 f"key='{self.key}', "
233 f"active={self.active}, "
234 f"verb={repr(verb)}, "
235 f"value={repr(self.value)})"
236 )
238 def get_verbs(self):
239 """
240 Returns the list of verbs supported by the filter.
241 """
242 verbs = None
244 if hasattr(self, "verbs"):
245 verbs = self.verbs
247 else:
248 verbs = self.default_verbs
250 if callable(verbs):
251 verbs = verbs()
252 verbs = list(verbs)
254 if self.nullable:
255 if "is_null" not in verbs:
256 verbs.append("is_null")
257 if "is_not_null" not in verbs:
258 verbs.append("is_not_null")
260 if "is_any" not in verbs:
261 verbs.append("is_any")
263 return verbs
265 def get_verb_labels(self):
266 """
267 Retrieve a dict of all defined verb labels for the filter.
269 This will be something along the lines of::
271 {
272 "equal": "equal to",
273 "not_equal": "not equal to",
274 "is_null": "is null",
275 "is_not_null": "is not null",
276 }
278 Note that this will crawl the class hierarchy, collecting all
279 labels defined (via :attr:`default_verb_labels`). Subclass
280 labels take precedence over base class labels.
282 :returns: Dict of verb labels, keyed by internal verb name.
283 """
284 labels = {}
285 for cls in get_class_hierarchy(self.__class__):
286 labels.update(cls.default_verb_labels)
287 for verb in self.get_verbs():
288 labels.setdefault(verb, verb)
289 return labels
291 def get_valueless_verbs(self):
292 """
293 Returns a list of verb names which do not need a value.
294 """
295 return self.valueless_verbs
297 def get_default_verb(self):
298 """
299 Returns the default verb for the filter.
300 """
301 verb = None
303 if hasattr(self, "default_verb"):
304 verb = self.default_verb
306 elif hasattr(self, "verb"):
307 verb = self.verb
309 if not verb:
310 verbs = self.get_verbs()
311 if verbs:
312 verb = verbs[0]
314 return verb
316 def set_choices(self, choices):
317 """
318 Set the value choices for the filter.
320 If ``choices`` is non-empty, it is passed to
321 :meth:`normalize_choices()` and the result is assigned to
322 :attr:`choices`. Also, the :attr:`data_type` is set to
323 ``'choice'`` so the UI will present the value input as a
324 dropdown.
326 But if ``choices`` is empty, :attr:`choices` is set to an
327 empty dict, and :attr:`data_type` is set (back) to
328 ``'string'``.
330 :param choices: Collection of "choices" or ``None``.
331 """
332 if choices:
333 self.choices = self.normalize_choices(choices)
334 self.data_type = "choice"
335 else:
336 self.choices = {}
337 if self.data_type == "choice":
338 self.data_type = self.original_data_type
340 def normalize_choices(self, choices):
341 """
342 Normalize a collection of "choices" to standard ``OrderedDict``.
344 This is called automatically by :meth:`set_choices()`.
346 :param choices: A collection of "choices" in one of the following
347 formats:
349 * :class:`python:enum.Enum` class
350 * simple list, each value of which should be a string,
351 which is assumed to be able to serve as both key and
352 value (ordering of choices will be preserved)
353 * simple dict, keys and values of which will define the
354 choices (note that the final choices will be sorted by
355 key!)
356 * OrderedDict, keys and values of which will define the
357 choices (ordering of choices will be preserved)
359 :rtype: :class:`python:collections.OrderedDict`
360 """
361 normalized = choices
363 if isinstance(choices, EnumType):
364 normalized = OrderedDict(
365 [(member.name, member.value) for member in choices]
366 )
368 elif isinstance(choices, OrderedDict):
369 normalized = choices
371 elif isinstance(choices, dict):
372 normalized = OrderedDict([(key, choices[key]) for key in sorted(choices)])
374 elif isinstance(choices, list):
375 normalized = OrderedDict([(key, key) for key in choices])
377 return normalized
379 def coerce_value(self, value):
380 """
381 Coerce the given value to the correct type/format for use with
382 the filter. This is where e.g. a boolean or date filter
383 should convert input string to ``bool`` or ``date`` value.
385 This is (usually) called from a filter method, when applying
386 the filter. See also :meth:`apply_filter()`.
388 Default logic on the base class returns value as-is; subclass
389 may override as needed.
391 :param value: Input string provided by the user via the filter
392 form submission.
394 :returns: Value of the appropriate type, depending on the
395 filter subclass.
396 """
397 return value
399 def apply_filter(self, data, verb=None, value=UNSPECIFIED):
400 """
401 Filter the given data set according to a verb/value pair.
403 If verb and/or value are not specified, will use :attr:`verb`
404 and/or :attr:`value` instead.
406 This method does not directly filter the data; rather it
407 delegates (based on ``verb``) to some other method. The
408 latter may choose *not* to filter the data, e.g. if ``value``
409 is empty, in which case this may return the original data set
410 unchanged.
412 :returns: The (possibly) filtered data set.
413 """
414 if verb is None:
415 verb = self.verb
416 if not verb:
417 verb = self.get_default_verb()
418 log.warning(
419 "missing verb for '%s' filter, will use default verb: %s",
420 self.key,
421 verb,
422 )
424 # only attempt for known verbs
425 if verb not in self.get_verbs():
426 raise VerbNotSupported(verb)
428 # fallback value
429 if value is UNSPECIFIED:
430 value = self.value
432 # locate filter method
433 func = getattr(self, f"filter_{verb}", None)
434 if not func:
435 raise VerbNotSupported(verb)
437 # invoke filter method
438 return func(data, value) # pylint: disable=not-callable
440 def filter_is_any(self, data, value): # pylint: disable=unused-argument
441 """
442 This is a no-op which always ignores the value and returns the
443 data as-is.
444 """
445 return data
448class AlchemyFilter(GridFilter):
449 """
450 Filter option for a grid with SQLAlchemy query data.
452 This is a subclass of :class:`GridFilter`. It requires a
453 ``model_property`` to know how to filter the query.
455 :param model_property: Property of a model class, representing the
456 column by which to filter. For instance,
457 ``model.Person.full_name``.
458 """
460 def __init__(self, *args, **kwargs):
461 self.model_property = kwargs.pop("model_property")
462 super().__init__(*args, **kwargs)
464 if self.nullable is None:
465 columns = self.model_property.prop.columns
466 if len(columns) == 1:
467 self.nullable = columns[0].nullable
469 def filter_equal(self, query, value):
470 """
471 Filter data with an equal (``=``) condition.
472 """
473 value = self.coerce_value(value)
474 if value is None:
475 return query
477 return query.filter(self.model_property == value)
479 def filter_not_equal(self, query, value):
480 """
481 Filter data with a not equal (``!=``) condition.
482 """
483 value = self.coerce_value(value)
484 if value is None:
485 return query
487 # sql probably excludes null values from results, but user
488 # probably does not expect that, so explicitly include them.
489 return query.filter(
490 sa.or_(
491 self.model_property == None, # pylint: disable=singleton-comparison
492 self.model_property != value,
493 )
494 )
496 def filter_greater_than(self, query, value):
497 """
498 Filter data with a greater than (``>``) condition.
499 """
500 value = self.coerce_value(value)
501 if value is None:
502 return query
503 return query.filter(self.model_property > value)
505 def filter_greater_equal(self, query, value):
506 """
507 Filter data with a greater than or equal (``>=``) condition.
508 """
509 value = self.coerce_value(value)
510 if value is None:
511 return query
512 return query.filter(self.model_property >= value)
514 def filter_less_than(self, query, value):
515 """
516 Filter data with a less than (``<``) condition.
517 """
518 value = self.coerce_value(value)
519 if value is None:
520 return query
521 return query.filter(self.model_property < value)
523 def filter_less_equal(self, query, value):
524 """
525 Filter data with a less than or equal (``<=``) condition.
526 """
527 value = self.coerce_value(value)
528 if value is None:
529 return query
530 return query.filter(self.model_property <= value)
532 def filter_is_null(self, query, value): # pylint: disable=unused-argument
533 """
534 Filter data with an ``IS NULL`` query. The value is ignored.
535 """
536 return query.filter(
537 self.model_property == None # pylint: disable=singleton-comparison
538 )
540 def filter_is_not_null(self, query, value): # pylint: disable=unused-argument
541 """
542 Filter data with an ``IS NOT NULL`` query. The value is
543 ignored.
544 """
545 return query.filter(
546 self.model_property != None # pylint: disable=singleton-comparison
547 )
550class StringAlchemyFilter(AlchemyFilter):
551 """
552 SQLAlchemy filter option for a text data column.
554 Subclass of :class:`AlchemyFilter`.
555 """
557 default_verbs = ["contains", "does_not_contain", "equal", "not_equal"]
559 def coerce_value(self, value): # pylint: disable=empty-docstring
560 """ """
561 if value is not None:
562 value = str(value)
563 if value:
564 return value
565 return None
567 def filter_contains(self, query, value):
568 """
569 Filter data with an ``ILIKE`` condition.
570 """
571 value = self.coerce_value(value)
572 if not value:
573 return query
575 criteria = []
576 for val in value.split():
577 val = val.replace("_", r"\_")
578 val = f"%{val}%"
579 criteria.append(self.model_property.ilike(val))
581 return query.filter(sa.and_(*criteria))
583 def filter_does_not_contain(self, query, value):
584 """
585 Filter data with a ``NOT ILIKE`` condition.
586 """
587 value = self.coerce_value(value)
588 if not value:
589 return query
591 criteria = []
592 for val in value.split():
593 val = val.replace("_", r"\_")
594 val = f"%{val}%"
595 criteria.append(~self.model_property.ilike(val))
597 # sql probably excludes null values from results, but user
598 # probably does not expect that, so explicitly include them.
599 return query.filter(
600 sa.or_(
601 self.model_property == None, # pylint: disable=singleton-comparison
602 sa.and_(*criteria),
603 )
604 )
607class NumericAlchemyFilter(AlchemyFilter):
608 """
609 SQLAlchemy filter option for a numeric data column.
611 Subclass of :class:`AlchemyFilter`.
612 """
614 default_verbs = [
615 "equal",
616 "not_equal",
617 "greater_than",
618 "greater_equal",
619 "less_than",
620 "less_equal",
621 ]
624class IntegerAlchemyFilter(NumericAlchemyFilter):
625 """
626 SQLAlchemy filter option for an integer data column.
628 Subclass of :class:`NumericAlchemyFilter`.
629 """
631 def coerce_value(self, value): # pylint: disable=empty-docstring
632 """ """
633 if value:
634 try:
635 return int(value)
636 except Exception: # pylint: disable=broad-exception-caught
637 pass
638 return None
641class BooleanAlchemyFilter(AlchemyFilter):
642 """
643 SQLAlchemy filter option for a boolean data column.
645 Subclass of :class:`AlchemyFilter`.
646 """
648 default_verbs = ["is_true", "is_false"]
650 def get_verbs(self): # pylint: disable=empty-docstring
651 """ """
653 # get basic verbs from caller, or default list
654 verbs = getattr(self, "verbs", self.default_verbs)
655 if callable(verbs):
656 verbs = verbs()
657 verbs = list(verbs)
659 # add some more if column is nullable
660 if self.nullable:
661 for verb in ("is_false_null", "is_null", "is_not_null"):
662 if verb not in verbs:
663 verbs.append(verb)
665 # add wildcard
666 if "is_any" not in verbs:
667 verbs.append("is_any")
669 return verbs
671 def coerce_value(self, value): # pylint: disable=empty-docstring
672 """ """
673 if value is not None:
674 return bool(value)
675 return None
677 def filter_is_true(self, query, value): # pylint: disable=unused-argument
678 """
679 Filter data with an "is true" condition. The value is
680 ignored.
681 """
682 return query.filter(
683 self.model_property == True # pylint: disable=singleton-comparison
684 )
686 def filter_is_false(self, query, value): # pylint: disable=unused-argument
687 """
688 Filter data with an "is false" condition. The value is
689 ignored.
690 """
691 return query.filter(
692 self.model_property == False # pylint: disable=singleton-comparison
693 )
695 def filter_is_false_null(self, query, value): # pylint: disable=unused-argument
696 """
697 Filter data with "is false or null" condition. The value is
698 ignored.
699 """
700 return query.filter(
701 sa.or_(
702 self.model_property == False, # pylint: disable=singleton-comparison
703 self.model_property == None, # pylint: disable=singleton-comparison
704 )
705 )
708class DateAlchemyFilter(AlchemyFilter):
709 """
710 SQLAlchemy filter option for a
711 :class:`~sqlalchemy:sqlalchemy.types.Date` column.
713 Subclass of :class:`AlchemyFilter`.
715 This filter class has custom logic to coerce the input value, but
716 does not have custom filter logic beyond that.
717 """
719 data_type = "date"
720 default_verbs = [
721 "equal",
722 "not_equal",
723 "greater_than",
724 "greater_equal",
725 "less_than",
726 "less_equal",
727 # 'between',
728 ]
730 default_verb_labels = {
731 "equal": "on",
732 "not_equal": "not on",
733 "greater_than": "after",
734 "greater_equal": "on or after",
735 "less_than": "before",
736 "less_equal": "on or before",
737 # 'between': "between",
738 }
740 def coerce_value(self, value):
741 """
742 Convert the given value to a proper
743 :class:`python:datetime.date` object, if applicable.
745 If the input value is already a date object, it is returned
746 as-is.
748 Otherwise it is assumed to be a string in ``%Y-%m-%d`` format,
749 and will be converted to a date object.
751 If the conversion fails, or no value is provided, ``None`` is
752 returned.
753 """
754 if value:
755 if isinstance(value, datetime.date):
756 return value
758 try:
759 dt = datetime.datetime.strptime(value, "%Y-%m-%d")
760 except ValueError:
761 log.warning("invalid date value: %s", value)
762 else:
763 return dt.date()
765 return None
768class DateTimeAlchemyFilter(DateAlchemyFilter):
769 """
770 SQLAlchemy filter option for a
771 :class:`~sqlalchemy:sqlalchemy.types.DateTime` column.
773 Subclass of :class:`DateAlchemyFilter`.
775 This filter class has custom logic to coerce the input value,
776 inherited from parent class. It also has custom filter logic
777 for most verbs (not/equal, greater/less than etc.).
779 Please note that this class assumes the underlying data uses
780 "naive UTC" values. It therefore will convert to/from local time
781 zone accordingly, to ensure user gets the data they expect. For
782 more info see :doc:`wuttjamaican:narr/datetime`.
783 """
785 def get_start_datetime(self, value, as_utc=True):
786 """
787 Calculate the "start" timestamp for the given date value.
789 The return value will be the "first possible moment" of the
790 given date.
792 :param value: :class:`python:datetime.date` instance
794 :param as_utc: Indicates the return value should be naive/UTC;
795 set this to ``False`` to get the aware/local value.
797 :returns: :class:`python:datetime.datetime` instance
798 """
799 start = datetime.datetime.combine(value, datetime.time(0))
800 start = self.app.localtime(start, from_utc=False)
801 if as_utc:
802 start = self.app.make_utc(start)
803 return start
805 def get_end_datetime(self, value, as_utc=True):
806 """
807 Calculate the "end" timestamp for the given date value.
809 Due to the nature of queries involving this "end" boundary,
810 the return value will be the "first possible moment" of the
811 day *after* the given date.
813 :param value: :class:`python:datetime.date` instance
815 :param as_utc: Indicates the return value should be naive/UTC;
816 set this to ``False`` to get the aware/local value.
818 :returns: :class:`python:datetime.datetime` instance
819 """
820 end = datetime.datetime.combine(
821 value + datetime.timedelta(days=1), datetime.time(0)
822 )
823 end = self.app.localtime(end, from_utc=False)
824 if as_utc:
825 end = self.app.make_utc(end)
826 return end
828 def filter_equal(self, query, value):
829 """
830 Find all records with datetime values which fall on the given
831 date.
832 """
833 value = self.coerce_value(value)
834 if value is None:
835 return query
837 start = self.get_start_datetime(value)
838 end = self.get_end_datetime(value)
839 return query.filter(self.model_property >= start).filter(
840 self.model_property < end
841 )
843 def filter_not_equal(self, query, value):
844 """
845 Find all records with datetime values which fall outside the
846 given date.
847 """
848 value = self.coerce_value(value)
849 if value is None:
850 return query
852 start = self.get_start_datetime(value)
853 end = self.get_end_datetime(value)
854 return query.filter(
855 sa.or_(self.model_property < start, self.model_property >= end)
856 )
858 def filter_greater_than(self, query, value):
859 """
860 Find all records with datetime values which fall after the
861 given date.
862 """
863 value = self.coerce_value(value)
864 if value is None:
865 return query
867 end = self.get_end_datetime(value)
868 return query.filter(self.model_property >= end)
870 def filter_greater_equal(self, query, value):
871 """
872 Find all records with datetime values which fall on or after
873 the given date.
874 """
875 value = self.coerce_value(value)
876 if value is None:
877 return query
879 start = self.get_start_datetime(value)
880 return query.filter(self.model_property >= start)
882 def filter_less_than(self, query, value):
883 """
884 Find all records with datetime values which fall before the
885 given date.
886 """
887 value = self.coerce_value(value)
888 if value is None:
889 return query
891 start = self.get_start_datetime(value)
892 return query.filter(self.model_property < start)
894 def filter_less_equal(self, query, value):
895 """
896 Find all records with datetime values which fall on or before
897 the given date.
898 """
899 value = self.coerce_value(value)
900 if value is None:
901 return query
903 end = self.get_end_datetime(value)
904 return query.filter(self.model_property < end)
907default_sqlalchemy_filters = {
908 None: AlchemyFilter,
909 sa.String: StringAlchemyFilter,
910 sa.Text: StringAlchemyFilter,
911 sa.Numeric: NumericAlchemyFilter,
912 sa.Integer: IntegerAlchemyFilter,
913 sa.Boolean: BooleanAlchemyFilter,
914 sa.Date: DateAlchemyFilter,
915 sa.DateTime: DateTimeAlchemyFilter,
916}