# -*- 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,
}