Source code for wuttaweb.grids.filters

# -*- coding: utf-8; -*-
################################################################################
#
#  wuttaweb -- Web App for Wutta Framework
#  Copyright © 2024-2026 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 datetime
import logging
from collections import OrderedDict

try:
    from enum import EnumType
except ImportError:  # pragma: no cover
    # nb. python <= 3.10
    from enum import EnumMeta as EnumType

import sqlalchemy as sa

from wuttjamaican.util import UNSPECIFIED


log = logging.getLogger(__name__)


class VerbNotSupported(Exception):  # pylint: disable=empty-docstring
    """ """

    def __init__(self, verb):
        self.verb = verb

    def __str__(self):
        return f"unknown filter verb not supported: {self.verb}"


[docs] class GridFilter: # pylint: disable=too-many-instance-attributes """ Filter option for a grid. Represents both the "features" as well as "state" for the filter. :param request: Current :term:`request` object. :param nullable: Boolean indicating whether the filter should include ``is_null`` and ``is_not_null`` verbs. If not specified, the column will be inspected (if possible) and use its nullable flag. :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:: data_type Simplistic "data type" which the filter supports. So far this will be one of: * ``'string'`` * ``'date'`` * ``'choice'`` Note that this mainly applies to the "value input" used by the filter. There is no data type for boolean since it does not need a value input; the verb is enough. .. 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:: choices OrderedDict of possible values for the filter. This is safe to read from, but use :meth:`set_choices()` to update it. .. 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`. """ data_type = "string" default_verbs = ["equal", "not_equal"] default_verb_labels = { "is_any": "is any", "equal": "equal to", "not_equal": "not equal to", "greater_than": "greater than", "greater_equal": "greater than or equal to", "less_than": "less than", "less_equal": "less than or equal to", # 'between': "between", "is_true": "is true", "is_false": "is false", "is_false_null": "is false or null", "is_null": "is null", "is_not_null": "is not null", "contains": "contains", "does_not_contain": "does not contain", } valueless_verbs = [ "is_any", "is_true", "is_false", "is_false_null", "is_null", "is_not_null", ] def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, request, key, label=None, verbs=None, choices=None, nullable=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) # remember original data type in case we need to revert, # e.g. after changing it to 'choices' and back again self.original_data_type = self.data_type # 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 self.verb = None # active verb is set later # choices self.set_choices(choices) # nullable self.nullable = nullable # 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)})" )
[docs] 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
[docs] def get_verb_labels(self): """ Returns a dict of all defined verb labels. """ # TODO: should traverse hierarchy labels = {verb: verb for verb in self.get_verbs()} labels.update(self.default_verb_labels) return labels
[docs] def get_valueless_verbs(self): """ Returns a list of verb names which do not need a value. """ return self.valueless_verbs
[docs] 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
[docs] def set_choices(self, choices): """ Set the value choices for the filter. If ``choices`` is non-empty, it is passed to :meth:`normalize_choices()` and the result is assigned to :attr:`choices`. Also, the :attr:`data_type` is set to ``'choice'`` so the UI will present the value input as a dropdown. But if ``choices`` is empty, :attr:`choices` is set to an empty dict, and :attr:`data_type` is set (back) to ``'string'``. :param choices: Collection of "choices" or ``None``. """ if choices: self.choices = self.normalize_choices(choices) self.data_type = "choice" else: self.choices = {} if self.data_type == "choice": self.data_type = self.original_data_type
[docs] def normalize_choices(self, choices): """ Normalize a collection of "choices" to standard ``OrderedDict``. This is called automatically by :meth:`set_choices()`. :param choices: A collection of "choices" in one of the following formats: * :class:`python:enum.Enum` class * simple list, each value of which should be a string, which is assumed to be able to serve as both key and value (ordering of choices will be preserved) * simple dict, keys and values of which will define the choices (note that the final choices will be sorted by key!) * OrderedDict, keys and values of which will define the choices (ordering of choices will be preserved) :rtype: :class:`python:collections.OrderedDict` """ normalized = choices if isinstance(choices, EnumType): normalized = OrderedDict( [(member.name, member.value) for member in choices] ) elif isinstance(choices, OrderedDict): normalized = choices elif isinstance(choices, dict): normalized = OrderedDict([(key, choices[key]) for key in sorted(choices)]) elif isinstance(choices, list): normalized = OrderedDict([(key, key) for key in choices]) return normalized
[docs] def coerce_value(self, value): """ Coerce the given value to the correct type/format for use with the filter. This is where e.g. a boolean or date filter should convert input string to ``bool`` or ``date`` value. This is (usually) called from a filter method, when applying the filter. See also :meth:`apply_filter()`. Default logic on the base class returns value as-is; subclass may override as needed. :param value: Input string provided by the user via the filter form submission. :returns: Value of the appropriate type, depending on the filter subclass. """ return value
[docs] 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.warning( "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) # pylint: disable=not-callable
[docs] def filter_is_any(self, data, value): # pylint: disable=unused-argument """ This is a no-op which always ignores the value and returns the data as-is. """ return data
[docs] 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``. """ def __init__(self, *args, **kwargs): self.model_property = kwargs.pop("model_property") super().__init__(*args, **kwargs) if self.nullable is None: columns = self.model_property.prop.columns if len(columns) == 1: self.nullable = columns[0].nullable
[docs] 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)
[docs] 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, # pylint: disable=singleton-comparison self.model_property != value, ) )
[docs] def filter_greater_than(self, query, value): """ Filter data with a greater than (``>``) condition. """ value = self.coerce_value(value) if value is None: return query return query.filter(self.model_property > value)
[docs] def filter_greater_equal(self, query, value): """ Filter data with a greater than or equal (``>=``) condition. """ value = self.coerce_value(value) if value is None: return query return query.filter(self.model_property >= value)
[docs] def filter_less_than(self, query, value): """ Filter data with a less than (``<``) condition. """ value = self.coerce_value(value) if value is None: return query return query.filter(self.model_property < value)
[docs] def filter_less_equal(self, query, value): """ Filter data with a less than or equal (``<=``) condition. """ value = self.coerce_value(value) if value is None: return query return query.filter(self.model_property <= value)
[docs] def filter_is_null(self, query, value): # pylint: disable=unused-argument """ Filter data with an ``IS NULL`` query. The value is ignored. """ return query.filter( self.model_property == None # pylint: disable=singleton-comparison )
[docs] def filter_is_not_null(self, query, value): # pylint: disable=unused-argument """ Filter data with an ``IS NOT NULL`` query. The value is ignored. """ return query.filter( self.model_property != None # pylint: disable=singleton-comparison )
[docs] 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): # pylint: disable=empty-docstring """ """ if value is not None: value = str(value) if value: return value return None
[docs] 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))
[docs] 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, # pylint: disable=singleton-comparison sa.and_(*criteria), ) )
[docs] class NumericAlchemyFilter(AlchemyFilter): """ SQLAlchemy filter option for a numeric data column. Subclass of :class:`AlchemyFilter`. """ default_verbs = [ "equal", "not_equal", "greater_than", "greater_equal", "less_than", "less_equal", ]
[docs] class IntegerAlchemyFilter(NumericAlchemyFilter): """ SQLAlchemy filter option for an integer data column. Subclass of :class:`NumericAlchemyFilter`. """ def coerce_value(self, value): # pylint: disable=empty-docstring """ """ if value: try: return int(value) except Exception: # pylint: disable=broad-exception-caught pass return None
[docs] class BooleanAlchemyFilter(AlchemyFilter): """ SQLAlchemy filter option for a boolean data column. Subclass of :class:`AlchemyFilter`. """ default_verbs = ["is_true", "is_false"] def get_verbs(self): # pylint: disable=empty-docstring """ """ # get basic verbs from caller, or default list verbs = getattr(self, "verbs", self.default_verbs) if callable(verbs): verbs = verbs() verbs = list(verbs) # add some more if column is nullable if self.nullable: for verb in ("is_false_null", "is_null", "is_not_null"): if verb not in verbs: verbs.append(verb) # add wildcard if "is_any" not in verbs: verbs.append("is_any") return verbs def coerce_value(self, value): # pylint: disable=empty-docstring """ """ if value is not None: return bool(value) return None
[docs] def filter_is_true(self, query, value): # pylint: disable=unused-argument """ Filter data with an "is true" condition. The value is ignored. """ return query.filter( self.model_property == True # pylint: disable=singleton-comparison )
[docs] def filter_is_false(self, query, value): # pylint: disable=unused-argument """ Filter data with an "is false" condition. The value is ignored. """ return query.filter( self.model_property == False # pylint: disable=singleton-comparison )
[docs] def filter_is_false_null(self, query, value): # pylint: disable=unused-argument """ Filter data with "is false or null" condition. The value is ignored. """ return query.filter( sa.or_( self.model_property == False, # pylint: disable=singleton-comparison self.model_property == None, # pylint: disable=singleton-comparison ) )
[docs] class DateAlchemyFilter(AlchemyFilter): """ SQLAlchemy filter option for a :class:`~sqlalchemy:sqlalchemy.types.Date` column. Subclass of :class:`AlchemyFilter`. This filter class has custom logic to coerce the input value, but does not have custom filter logic beyond that. """ data_type = "date" default_verbs = [ "equal", "not_equal", "greater_than", "greater_equal", "less_than", "less_equal", # 'between', ] default_verb_labels = { "equal": "on", "not_equal": "not on", "greater_than": "after", "greater_equal": "on or after", "less_than": "before", "less_equal": "on or before", # 'between': "between", "is_any": "is any", }
[docs] def coerce_value(self, value): """ Convert the given value to a proper :class:`python:datetime.date` object, if applicable. If the input value is already a date object, it is returned as-is. Otherwise it is assumed to be a string in ``%Y-%m-%d`` format, and will be converted to a date object. If the conversion fails, or no value is provided, ``None`` is returned. """ if value: if isinstance(value, datetime.date): return value try: dt = datetime.datetime.strptime(value, "%Y-%m-%d") except ValueError: log.warning("invalid date value: %s", value) else: return dt.date() return None
[docs] class DateTimeAlchemyFilter(DateAlchemyFilter): """ SQLAlchemy filter option for a :class:`~sqlalchemy:sqlalchemy.types.DateTime` column. Subclass of :class:`DateAlchemyFilter`. This filter class has custom logic to coerce the input value, inherited from parent class. It also has custom filter logic for most verbs (not/equal, greater/less than etc.). Please note that this class assumes the underlying data uses "naive UTC" values. It therefore will convert to/from local time zone accordingly, to ensure user gets the data they expect. For more info see :doc:`wuttjamaican:narr/datetime`. """
[docs] def get_start_datetime(self, value, as_utc=True): """ Calculate the "start" timestamp for the given date value. The return value will be the "first possible moment" of the given date. :param value: :class:`python:datetime.date` instance :param as_utc: Indicates the return value should be naive/UTC; set this to ``False`` to get the aware/local value. :returns: :class:`python:datetime.datetime` instance """ start = datetime.datetime.combine(value, datetime.time(0)) start = self.app.localtime(start, from_utc=False) if as_utc: start = self.app.make_utc(start) return start
[docs] def get_end_datetime(self, value, as_utc=True): """ Calculate the "end" timestamp for the given date value. Due to the nature of queries involving this "end" boundary, the return value will be the "first possible moment" of the day *after* the given date. :param value: :class:`python:datetime.date` instance :param as_utc: Indicates the return value should be naive/UTC; set this to ``False`` to get the aware/local value. :returns: :class:`python:datetime.datetime` instance """ end = datetime.datetime.combine( value + datetime.timedelta(days=1), datetime.time(0) ) end = self.app.localtime(end, from_utc=False) if as_utc: end = self.app.make_utc(end) return end
[docs] def filter_equal(self, query, value): """ Find all records with datetime values which fall on the given date. """ value = self.coerce_value(value) if value is None: return query start = self.get_start_datetime(value) end = self.get_end_datetime(value) return query.filter(self.model_property >= start).filter( self.model_property < end )
[docs] def filter_not_equal(self, query, value): """ Find all records with datetime values which fall outside the given date. """ value = self.coerce_value(value) if value is None: return query start = self.get_start_datetime(value) end = self.get_end_datetime(value) return query.filter( sa.or_(self.model_property < start, self.model_property >= end) )
[docs] def filter_greater_than(self, query, value): """ Find all records with datetime values which fall after the given date. """ value = self.coerce_value(value) if value is None: return query end = self.get_end_datetime(value) return query.filter(self.model_property >= end)
[docs] def filter_greater_equal(self, query, value): """ Find all records with datetime values which fall on or after the given date. """ value = self.coerce_value(value) if value is None: return query start = self.get_start_datetime(value) return query.filter(self.model_property >= start)
[docs] def filter_less_than(self, query, value): """ Find all records with datetime values which fall before the given date. """ value = self.coerce_value(value) if value is None: return query start = self.get_start_datetime(value) return query.filter(self.model_property < start)
[docs] def filter_less_equal(self, query, value): """ Find all records with datetime values which fall on or before the given date. """ value = self.coerce_value(value) if value is None: return query end = self.get_end_datetime(value) return query.filter(self.model_property < end)
default_sqlalchemy_filters = { None: AlchemyFilter, sa.String: StringAlchemyFilter, sa.Text: StringAlchemyFilter, sa.Numeric: NumericAlchemyFilter, sa.Integer: IntegerAlchemyFilter, sa.Boolean: BooleanAlchemyFilter, sa.Date: DateAlchemyFilter, sa.DateTime: DateTimeAlchemyFilter, }