Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / grids / filters.py: 100%
279 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-21 19:06 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-21 19:06 -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
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 # remember original data type in case we need to revert,
193 # e.g. after changing it to 'choices' and back again
194 self.original_data_type = self.data_type
196 # active
197 self.default_active = default_active
198 self.active = self.default_active
200 # verb
201 if verbs is not None:
202 self.verbs = verbs
203 if default_verb:
204 self.default_verb = default_verb
205 self.verb = None # active verb is set later
207 # choices
208 self.set_choices(choices)
210 # nullable
211 self.nullable = nullable
213 # value
214 self.default_value = default_value
215 self.value = self.default_value
217 self.__dict__.update(kwargs)
219 def __repr__(self):
220 verb = getattr(self, "verb", None)
221 return (
222 f"{self.__class__.__name__}("
223 f"key='{self.key}', "
224 f"active={self.active}, "
225 f"verb={repr(verb)}, "
226 f"value={repr(self.value)})"
227 )
229 def get_verbs(self):
230 """
231 Returns the list of verbs supported by the filter.
232 """
233 verbs = None
235 if hasattr(self, "verbs"):
236 verbs = self.verbs
238 else:
239 verbs = self.default_verbs
241 if callable(verbs):
242 verbs = verbs()
243 verbs = list(verbs)
245 if self.nullable:
246 if "is_null" not in verbs:
247 verbs.append("is_null")
248 if "is_not_null" not in verbs:
249 verbs.append("is_not_null")
251 if "is_any" not in verbs:
252 verbs.append("is_any")
254 return verbs
256 def get_verb_labels(self):
257 """
258 Returns a dict of all defined verb labels.
259 """
260 # TODO: should traverse hierarchy
261 labels = {verb: verb for verb in self.get_verbs()}
262 labels.update(self.default_verb_labels)
263 return labels
265 def get_valueless_verbs(self):
266 """
267 Returns a list of verb names which do not need a value.
268 """
269 return self.valueless_verbs
271 def get_default_verb(self):
272 """
273 Returns the default verb for the filter.
274 """
275 verb = None
277 if hasattr(self, "default_verb"):
278 verb = self.default_verb
280 elif hasattr(self, "verb"):
281 verb = self.verb
283 if not verb:
284 verbs = self.get_verbs()
285 if verbs:
286 verb = verbs[0]
288 return verb
290 def set_choices(self, choices):
291 """
292 Set the value choices for the filter.
294 If ``choices`` is non-empty, it is passed to
295 :meth:`normalize_choices()` and the result is assigned to
296 :attr:`choices`. Also, the :attr:`data_type` is set to
297 ``'choice'`` so the UI will present the value input as a
298 dropdown.
300 But if ``choices`` is empty, :attr:`choices` is set to an
301 empty dict, and :attr:`data_type` is set (back) to
302 ``'string'``.
304 :param choices: Collection of "choices" or ``None``.
305 """
306 if choices:
307 self.choices = self.normalize_choices(choices)
308 self.data_type = "choice"
309 else:
310 self.choices = {}
311 if self.data_type == "choice":
312 self.data_type = self.original_data_type
314 def normalize_choices(self, choices):
315 """
316 Normalize a collection of "choices" to standard ``OrderedDict``.
318 This is called automatically by :meth:`set_choices()`.
320 :param choices: A collection of "choices" in one of the following
321 formats:
323 * :class:`python:enum.Enum` class
324 * simple list, each value of which should be a string,
325 which is assumed to be able to serve as both key and
326 value (ordering of choices will be preserved)
327 * simple dict, keys and values of which will define the
328 choices (note that the final choices will be sorted by
329 key!)
330 * OrderedDict, keys and values of which will define the
331 choices (ordering of choices will be preserved)
333 :rtype: :class:`python:collections.OrderedDict`
334 """
335 normalized = choices
337 if isinstance(choices, EnumType):
338 normalized = OrderedDict(
339 [(member.name, member.value) for member in choices]
340 )
342 elif isinstance(choices, OrderedDict):
343 normalized = choices
345 elif isinstance(choices, dict):
346 normalized = OrderedDict([(key, choices[key]) for key in sorted(choices)])
348 elif isinstance(choices, list):
349 normalized = OrderedDict([(key, key) for key in choices])
351 return normalized
353 def coerce_value(self, value):
354 """
355 Coerce the given value to the correct type/format for use with
356 the filter. This is where e.g. a boolean or date filter
357 should convert input string to ``bool`` or ``date`` value.
359 This is (usually) called from a filter method, when applying
360 the filter. See also :meth:`apply_filter()`.
362 Default logic on the base class returns value as-is; subclass
363 may override as needed.
365 :param value: Input string provided by the user via the filter
366 form submission.
368 :returns: Value of the appropriate type, depending on the
369 filter subclass.
370 """
371 return value
373 def apply_filter(self, data, verb=None, value=UNSPECIFIED):
374 """
375 Filter the given data set according to a verb/value pair.
377 If verb and/or value are not specified, will use :attr:`verb`
378 and/or :attr:`value` instead.
380 This method does not directly filter the data; rather it
381 delegates (based on ``verb``) to some other method. The
382 latter may choose *not* to filter the data, e.g. if ``value``
383 is empty, in which case this may return the original data set
384 unchanged.
386 :returns: The (possibly) filtered data set.
387 """
388 if verb is None:
389 verb = self.verb
390 if not verb:
391 verb = self.get_default_verb()
392 log.warning(
393 "missing verb for '%s' filter, will use default verb: %s",
394 self.key,
395 verb,
396 )
398 # only attempt for known verbs
399 if verb not in self.get_verbs():
400 raise VerbNotSupported(verb)
402 # fallback value
403 if value is UNSPECIFIED:
404 value = self.value
406 # locate filter method
407 func = getattr(self, f"filter_{verb}", None)
408 if not func:
409 raise VerbNotSupported(verb)
411 # invoke filter method
412 return func(data, value) # pylint: disable=not-callable
414 def filter_is_any(self, data, value): # pylint: disable=unused-argument
415 """
416 This is a no-op which always ignores the value and returns the
417 data as-is.
418 """
419 return data
422class AlchemyFilter(GridFilter):
423 """
424 Filter option for a grid with SQLAlchemy query data.
426 This is a subclass of :class:`GridFilter`. It requires a
427 ``model_property`` to know how to filter the query.
429 :param model_property: Property of a model class, representing the
430 column by which to filter. For instance,
431 ``model.Person.full_name``.
432 """
434 def __init__(self, *args, **kwargs):
435 self.model_property = kwargs.pop("model_property")
436 super().__init__(*args, **kwargs)
438 if self.nullable is None:
439 columns = self.model_property.prop.columns
440 if len(columns) == 1:
441 self.nullable = columns[0].nullable
443 def filter_equal(self, query, value):
444 """
445 Filter data with an equal (``=``) condition.
446 """
447 value = self.coerce_value(value)
448 if value is None:
449 return query
451 return query.filter(self.model_property == value)
453 def filter_not_equal(self, query, value):
454 """
455 Filter data with a not equal (``!=``) condition.
456 """
457 value = self.coerce_value(value)
458 if value is None:
459 return query
461 # sql probably excludes null values from results, but user
462 # probably does not expect that, so explicitly include them.
463 return query.filter(
464 sa.or_(
465 self.model_property == None, # pylint: disable=singleton-comparison
466 self.model_property != value,
467 )
468 )
470 def filter_greater_than(self, query, value):
471 """
472 Filter data with a greater than (``>``) condition.
473 """
474 value = self.coerce_value(value)
475 if value is None:
476 return query
477 return query.filter(self.model_property > value)
479 def filter_greater_equal(self, query, value):
480 """
481 Filter data with a greater than or equal (``>=``) condition.
482 """
483 value = self.coerce_value(value)
484 if value is None:
485 return query
486 return query.filter(self.model_property >= value)
488 def filter_less_than(self, query, value):
489 """
490 Filter data with a less than (``<``) condition.
491 """
492 value = self.coerce_value(value)
493 if value is None:
494 return query
495 return query.filter(self.model_property < value)
497 def filter_less_equal(self, query, value):
498 """
499 Filter data with a less than or equal (``<=``) condition.
500 """
501 value = self.coerce_value(value)
502 if value is None:
503 return query
504 return query.filter(self.model_property <= value)
506 def filter_is_null(self, query, value): # pylint: disable=unused-argument
507 """
508 Filter data with an ``IS NULL`` query. The value is ignored.
509 """
510 return query.filter(
511 self.model_property == None # pylint: disable=singleton-comparison
512 )
514 def filter_is_not_null(self, query, value): # pylint: disable=unused-argument
515 """
516 Filter data with an ``IS NOT NULL`` query. The value is
517 ignored.
518 """
519 return query.filter(
520 self.model_property != None # pylint: disable=singleton-comparison
521 )
524class StringAlchemyFilter(AlchemyFilter):
525 """
526 SQLAlchemy filter option for a text data column.
528 Subclass of :class:`AlchemyFilter`.
529 """
531 default_verbs = ["contains", "does_not_contain", "equal", "not_equal"]
533 def coerce_value(self, value): # pylint: disable=empty-docstring
534 """ """
535 if value is not None:
536 value = str(value)
537 if value:
538 return value
539 return None
541 def filter_contains(self, query, value):
542 """
543 Filter data with an ``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 return query.filter(sa.and_(*criteria))
557 def filter_does_not_contain(self, query, value):
558 """
559 Filter data with a ``NOT ILIKE`` condition.
560 """
561 value = self.coerce_value(value)
562 if not value:
563 return query
565 criteria = []
566 for val in value.split():
567 val = val.replace("_", r"\_")
568 val = f"%{val}%"
569 criteria.append(~self.model_property.ilike(val))
571 # sql probably excludes null values from results, but user
572 # probably does not expect that, so explicitly include them.
573 return query.filter(
574 sa.or_(
575 self.model_property == None, # pylint: disable=singleton-comparison
576 sa.and_(*criteria),
577 )
578 )
581class NumericAlchemyFilter(AlchemyFilter):
582 """
583 SQLAlchemy filter option for a numeric data column.
585 Subclass of :class:`AlchemyFilter`.
586 """
588 default_verbs = [
589 "equal",
590 "not_equal",
591 "greater_than",
592 "greater_equal",
593 "less_than",
594 "less_equal",
595 ]
598class IntegerAlchemyFilter(NumericAlchemyFilter):
599 """
600 SQLAlchemy filter option for an integer data column.
602 Subclass of :class:`NumericAlchemyFilter`.
603 """
605 def coerce_value(self, value): # pylint: disable=empty-docstring
606 """ """
607 if value:
608 try:
609 return int(value)
610 except Exception: # pylint: disable=broad-exception-caught
611 pass
612 return None
615class BooleanAlchemyFilter(AlchemyFilter):
616 """
617 SQLAlchemy filter option for a boolean data column.
619 Subclass of :class:`AlchemyFilter`.
620 """
622 default_verbs = ["is_true", "is_false"]
624 def get_verbs(self): # pylint: disable=empty-docstring
625 """ """
627 # get basic verbs from caller, or default list
628 verbs = getattr(self, "verbs", self.default_verbs)
629 if callable(verbs):
630 verbs = verbs()
631 verbs = list(verbs)
633 # add some more if column is nullable
634 if self.nullable:
635 for verb in ("is_false_null", "is_null", "is_not_null"):
636 if verb not in verbs:
637 verbs.append(verb)
639 # add wildcard
640 if "is_any" not in verbs:
641 verbs.append("is_any")
643 return verbs
645 def coerce_value(self, value): # pylint: disable=empty-docstring
646 """ """
647 if value is not None:
648 return bool(value)
649 return None
651 def filter_is_true(self, query, value): # pylint: disable=unused-argument
652 """
653 Filter data with an "is true" condition. The value is
654 ignored.
655 """
656 return query.filter(
657 self.model_property == True # pylint: disable=singleton-comparison
658 )
660 def filter_is_false(self, query, value): # pylint: disable=unused-argument
661 """
662 Filter data with an "is false" condition. The value is
663 ignored.
664 """
665 return query.filter(
666 self.model_property == False # pylint: disable=singleton-comparison
667 )
669 def filter_is_false_null(self, query, value): # pylint: disable=unused-argument
670 """
671 Filter data with "is false or null" condition. The value is
672 ignored.
673 """
674 return query.filter(
675 sa.or_(
676 self.model_property == False, # pylint: disable=singleton-comparison
677 self.model_property == None, # pylint: disable=singleton-comparison
678 )
679 )
682class DateAlchemyFilter(AlchemyFilter):
683 """
684 SQLAlchemy filter option for a
685 :class:`~sqlalchemy:sqlalchemy.types.Date` column.
687 Subclass of :class:`AlchemyFilter`.
689 This filter class has custom logic to coerce the input value, but
690 does not have custom filter logic beyond that.
691 """
693 data_type = "date"
694 default_verbs = [
695 "equal",
696 "not_equal",
697 "greater_than",
698 "greater_equal",
699 "less_than",
700 "less_equal",
701 # 'between',
702 ]
704 default_verb_labels = {
705 "equal": "on",
706 "not_equal": "not on",
707 "greater_than": "after",
708 "greater_equal": "on or after",
709 "less_than": "before",
710 "less_equal": "on or before",
711 # 'between': "between",
712 "is_any": "is any",
713 }
715 def coerce_value(self, value):
716 """
717 Convert the given value to a proper
718 :class:`python:datetime.date` object, if applicable.
720 If the input value is already a date object, it is returned
721 as-is.
723 Otherwise it is assumed to be a string in ``%Y-%m-%d`` format,
724 and will be converted to a date object.
726 If the conversion fails, or no value is provided, ``None`` is
727 returned.
728 """
729 if value:
730 if isinstance(value, datetime.date):
731 return value
733 try:
734 dt = datetime.datetime.strptime(value, "%Y-%m-%d")
735 except ValueError:
736 log.warning("invalid date value: %s", value)
737 else:
738 return dt.date()
740 return None
743class DateTimeAlchemyFilter(DateAlchemyFilter):
744 """
745 SQLAlchemy filter option for a
746 :class:`~sqlalchemy:sqlalchemy.types.DateTime` column.
748 Subclass of :class:`DateAlchemyFilter`.
750 This filter class has custom logic to coerce the input value,
751 inherited from parent class. It also has custom filter logic
752 for most verbs (not/equal, greater/less than etc.).
754 Please note that this class assumes the underlying data uses
755 "naive UTC" values. It therefore will convert to/from local time
756 zone accordingly, to ensure user gets the data they expect. For
757 more info see :doc:`wuttjamaican:narr/datetime`.
758 """
760 def get_start_datetime(self, value, as_utc=True):
761 """
762 Calculate the "start" timestamp for the given date value.
764 The return value will be the "first possible moment" of the
765 given date.
767 :param value: :class:`python:datetime.date` instance
769 :param as_utc: Indicates the return value should be naive/UTC;
770 set this to ``False`` to get the aware/local value.
772 :returns: :class:`python:datetime.datetime` instance
773 """
774 start = datetime.datetime.combine(value, datetime.time(0))
775 start = self.app.localtime(start, from_utc=False)
776 if as_utc:
777 start = self.app.make_utc(start)
778 return start
780 def get_end_datetime(self, value, as_utc=True):
781 """
782 Calculate the "end" timestamp for the given date value.
784 Due to the nature of queries involving this "end" boundary,
785 the return value will be the "first possible moment" of the
786 day *after* the given date.
788 :param value: :class:`python:datetime.date` instance
790 :param as_utc: Indicates the return value should be naive/UTC;
791 set this to ``False`` to get the aware/local value.
793 :returns: :class:`python:datetime.datetime` instance
794 """
795 end = datetime.datetime.combine(
796 value + datetime.timedelta(days=1), datetime.time(0)
797 )
798 end = self.app.localtime(end, from_utc=False)
799 if as_utc:
800 end = self.app.make_utc(end)
801 return end
803 def filter_equal(self, query, value):
804 """
805 Find all records with datetime values which fall on the given
806 date.
807 """
808 value = self.coerce_value(value)
809 if value is None:
810 return query
812 start = self.get_start_datetime(value)
813 end = self.get_end_datetime(value)
814 return query.filter(self.model_property >= start).filter(
815 self.model_property < end
816 )
818 def filter_not_equal(self, query, value):
819 """
820 Find all records with datetime values which fall outside the
821 given date.
822 """
823 value = self.coerce_value(value)
824 if value is None:
825 return query
827 start = self.get_start_datetime(value)
828 end = self.get_end_datetime(value)
829 return query.filter(
830 sa.or_(self.model_property < start, self.model_property >= end)
831 )
833 def filter_greater_than(self, query, value):
834 """
835 Find all records with datetime values which fall after the
836 given date.
837 """
838 value = self.coerce_value(value)
839 if value is None:
840 return query
842 end = self.get_end_datetime(value)
843 return query.filter(self.model_property >= end)
845 def filter_greater_equal(self, query, value):
846 """
847 Find all records with datetime values which fall on or after
848 the given date.
849 """
850 value = self.coerce_value(value)
851 if value is None:
852 return query
854 start = self.get_start_datetime(value)
855 return query.filter(self.model_property >= start)
857 def filter_less_than(self, query, value):
858 """
859 Find all records with datetime values which fall before the
860 given date.
861 """
862 value = self.coerce_value(value)
863 if value is None:
864 return query
866 start = self.get_start_datetime(value)
867 return query.filter(self.model_property < start)
869 def filter_less_equal(self, query, value):
870 """
871 Find all records with datetime values which fall on or before
872 the given date.
873 """
874 value = self.coerce_value(value)
875 if value is None:
876 return query
878 end = self.get_end_datetime(value)
879 return query.filter(self.model_property < end)
882default_sqlalchemy_filters = {
883 None: AlchemyFilter,
884 sa.String: StringAlchemyFilter,
885 sa.Text: StringAlchemyFilter,
886 sa.Numeric: NumericAlchemyFilter,
887 sa.Integer: IntegerAlchemyFilter,
888 sa.Boolean: BooleanAlchemyFilter,
889 sa.Date: DateAlchemyFilter,
890 sa.DateTime: DateTimeAlchemyFilter,
891}