tailbone/tailbone/grids/filters.py
Lance Edgar 3688979b8f Add integer-specific grid filter
this was necessary for smarter handling of "invalid" input, e.g. '.645' is not
a good value when querying integer fields
2018-06-05 11:08:36 -05:00

801 lines
25 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Grid Filters
"""
from __future__ import unicode_literals, absolute_import
import datetime
import logging
import six
import sqlalchemy as sa
from rattail.gpc import GPC
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED
from rattail.time import localtime, make_utc
from rattail.util import prettify
import colander
from webhelpers2.html import HTML, tags
from tailbone import forms
log = logging.getLogger(__name__)
class FilterValueRenderer(object):
"""
Base class for all filter renderers.
"""
@property
def name(self):
return self.filter.key
def render(self, value=None, **kwargs):
"""
Render the filter input element(s) as HTML. Default implementation
uses a simple text input.
"""
return tags.text(self.name, value=value, **kwargs)
class DefaultValueRenderer(FilterValueRenderer):
"""
Default / fallback renderer.
"""
class NumericValueRenderer(FilterValueRenderer):
"""
Input renderer for numeric values.
"""
def render(self, value=None, **kwargs):
kwargs.setdefault('step', '0.001')
return tags.text(self.name, value=value, type='number', **kwargs)
class DateValueRenderer(FilterValueRenderer):
"""
Input renderer for date values.
"""
def render(self, value=None, **kwargs):
return tags.text(self.name, value=value, type='date', **kwargs)
class ChoiceValueRenderer(FilterValueRenderer):
"""
Renders value input as a dropdown/selectmenu of available choices.
"""
def __init__(self, options):
self.options = options
def render(self, value=None, **kwargs):
return tags.select(self.name, [value], self.options, **kwargs)
class EnumValueRenderer(ChoiceValueRenderer):
"""
Renders value input as a dropdown/selectmenu of available choices.
"""
def __init__(self, enum):
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
class GridFilter(object):
"""
Represents a filter available to a grid. This is used to construct the
'filters' section when rendering the index page template.
"""
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",
'is_null': "is null",
'is_not_null': "is not null",
'is_true': "is true",
'is_false': "is false",
'contains': "contains",
'does_not_contain': "does not contain",
'is_me': "is me",
'is_not_me': "is not me",
}
valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false',
'is_me', 'is_not_me']
value_renderer_factory = DefaultValueRenderer
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
default_active=False, default_verb=None, default_value=None,
encode_values=False, value_encoding='utf-8', **kwargs):
self.key = key
self.label = label or prettify(key)
self.verbs = verbs or self.get_default_verbs()
if value_renderer:
self.set_value_renderer(value_renderer)
elif value_enum:
self.set_value_renderer(EnumValueRenderer(value_enum))
else:
self.set_value_renderer(self.value_renderer_factory)
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
self.encode_values = encode_values
self.value_encoding = value_encoding
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self):
return "{}({})".format(self.__class__.__name__, repr(self.key))
def get_default_verbs(self):
"""
Returns the set of verbs which will be used by default, i.e. unless
overridden by constructor args etc.
"""
verbs = getattr(self, 'default_verbs', None)
if verbs:
if callable(verbs):
return verbs()
return verbs
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
def set_value_renderer(self, renderer):
"""
Set the value renderer for the filter, post-construction.
"""
if not isinstance(renderer, FilterValueRenderer):
renderer = renderer()
renderer.filter = self
self.value_renderer = renderer
def filter(self, data, verb=None, value=UNSPECIFIED):
"""
Filter the given data set according to a verb/value pair. If no verb
and/or value is specified by the caller, the filter will use its own
current verb/value by default.
"""
verb = verb or self.verb
value = self.get_value(value)
filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr:
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
return filtr(data, value)
def get_value(self, value=UNSPECIFIED):
return value if value is not UNSPECIFIED else self.value
def encode_value(self, value):
if self.encode_values and isinstance(value, six.string_types):
return value.encode('utf-8')
return value
def filter_is_any(self, data, value):
"""
Special no-op filter which does no actual filtering. Useful in some
cases to add an "ineffective" option to the verb list for a given grid
filter.
"""
return data
def render_value(self, value=UNSPECIFIED, **kwargs):
"""
Render the HTML needed to expose the filter's value for user input.
"""
if value is UNSPECIFIED:
value = self.value
kwargs['filtr'] = self
return self.value_renderer.render(value=value, **kwargs)
class MobileFilter(GridFilter):
"""
Base class for mobile grid filters.
"""
default_verbs = ['equal']
def __init__(self, key, **kwargs):
kwargs.setdefault('default_active', True)
kwargs.setdefault('default_verb', 'equal')
super(MobileFilter, self).__init__(key, **kwargs)
class AlchemyGridFilter(GridFilter):
"""
Base class for SQLAlchemy grid filters.
"""
def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column')
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
Filter data with an equal ('=') query.
"""
if value is None or value == '':
return query
return query.filter(self.column == self.encode_value(value))
def filter_not_equal(self, query, value):
"""
Filter data with a not eqaul ('!=') query.
"""
if value is None or value == '':
return query
# When saying something is 'not equal' to something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
self.column != self.encode_value(value),
))
def filter_is_null(self, query, value):
"""
Filter data with an 'IS NULL' query. Note that this filter does not
use the value for anything.
"""
return query.filter(self.column == None)
def filter_is_not_null(self, query, value):
"""
Filter data with an 'IS NOT NULL' query. Note that this filter does
not use the value for anything.
"""
return query.filter(self.column != None)
def filter_greater_than(self, query, value):
"""
Filter data with a greater than ('>') query.
"""
if value is None or value == '':
return query
return query.filter(self.column > self.encode_value(value))
def filter_greater_equal(self, query, value):
"""
Filter data with a greater than or equal ('>=') query.
"""
if value is None or value == '':
return query
return query.filter(self.column >= self.encode_value(value))
def filter_less_than(self, query, value):
"""
Filter data with a less than ('<') query.
"""
if value is None or value == '':
return query
return query.filter(self.column < self.encode_value(value))
def filter_less_equal(self, query, value):
"""
Filter data with a less than or equal ('<=') query.
"""
if value is None or value == '':
return query
return query.filter(self.column <= self.encode_value(value))
class AlchemyStringFilter(AlchemyGridFilter):
"""
String filter for SQLAlchemy.
"""
def default_verbs(self):
"""
Expose contains / does-not-contain verbs in addition to core.
"""
return ['contains', 'does_not_contain',
'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
def filter_contains(self, query, value):
"""
Filter data with a full 'ILIKE' query.
"""
if value is None or value == '':
return query
return query.filter(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
Filter data with a full 'NOT ILIKE' query.
"""
if value is None or value == '':
return query
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]),
))
class AlchemyEmptyStringFilter(AlchemyStringFilter):
"""
String filter with special logic to treat empty string values as NULL
"""
def filter_is_null(self, query, value):
return query.filter(
sa.or_(
self.column == None,
sa.func.trim(self.column) == self.encode_value('')))
def filter_is_not_null(self, query, value):
return query.filter(
sa.and_(
self.column != None,
sa.func.trim(self.column) != self.encode_value('')))
class AlchemyByteStringFilter(AlchemyStringFilter):
"""
String filter for SQLAlchemy, which encodes value as bytestring before
passing it along to the query. Useful when querying certain databases
(esp. via FreeTDS) which like to throw the occasional segfault...
"""
value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED):
value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, six.text_type):
value = value.encode(self.value_encoding)
return value
def filter_contains(self, query, value):
"""
Filter data with a full 'ILIKE' query.
"""
if value is None or value == '':
return query
return query.filter(sa.and_(
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
Filter data with a full 'NOT ILIKE' query.
"""
if value is None or value == '':
return query
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter):
"""
Numeric filter for SQLAlchemy.
"""
value_renderer_factory = NumericValueRenderer
# expose greater-than / less-than verbs in addition to core
default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
# TODO: what follows "works" in that it prevents an error...but from the
# user's perspective it still fails silently...need to improve on front-end
# try to detect (and ignore) common mistake where user enters UPC as search
# term for integer field...
def value_invalid(self, value):
return bool(value and len(six.text_type(value)) > 8)
def filter_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_equal(query, value)
def filter_not_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
def filter_greater_than(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
def filter_greater_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
def filter_less_than(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
def filter_less_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
class AlchemyIntegerFilter(AlchemyNumericFilter):
"""
Integer filter for SQLAlchemy.
"""
def value_invalid(self, value):
if value:
if not value.isdigit():
return True
return False
class AlchemyBooleanFilter(AlchemyGridFilter):
"""
Boolean filter for SQLAlchemy.
"""
default_verbs = ['is_true', 'is_false', 'is_any']
def filter_is_true(self, query, value):
"""
Filter data with an "is true" query. Note that this filter does not
use the value for anything.
"""
return query.filter(self.column == True)
def filter_is_false(self, query, value):
"""
Filter data with an "is false" query. Note that this filter does not
use the value for anything.
"""
return query.filter(self.column == False)
class AlchemyNullableBooleanFilter(AlchemyBooleanFilter):
"""
Boolean filter for SQLAlchemy which is NULL-aware.
"""
default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any']
class AlchemyDateFilter(AlchemyGridFilter):
"""
Date filter for SQLAlchemy.
"""
value_renderer_factory = DateValueRenderer
verb_labels = {
'equal': "on",
'not_equal': "not on",
'greater_than': "after",
'greater_equal': "on or after",
'less_than': "before",
'less_equal': "on or before",
'is_null': "is null",
'is_not_null': "is not null",
'is_any': "is any",
}
def default_verbs(self):
"""
Expose greater-than / less-than verbs in addition to core.
"""
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
def make_date(self, value):
"""
Convert user input to a proper ``datetime.date`` object.
"""
if value:
try:
dt = datetime.datetime.strptime(value, '%Y-%m-%d')
except ValueError:
log.warning("invalid date value: {}".format(value))
else:
return dt.date()
class AlchemyDateTimeFilter(AlchemyDateFilter):
"""
SQLAlchemy filter for datetime values.
"""
def filter_equal(self, query, value):
"""
Find all dateimes which fall on the given date.
"""
date = self.make_date(value)
if not date:
return query
start = datetime.datetime.combine(date, datetime.time(0))
start = make_utc(localtime(self.config, start))
stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
stop = make_utc(localtime(self.config, stop))
return query.filter(self.column >= start)\
.filter(self.column < stop)
def filter_not_equal(self, query, value):
"""
Find all dateimes which do *not* fall on the given date.
"""
date = self.make_date(value)
if not date:
return query
start = datetime.datetime.combine(date, datetime.time(0))
start = make_utc(localtime(self.config, start))
stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
stop = make_utc(localtime(self.config, stop))
return query.filter(sa.or_(
self.column < start,
self.column <= stop))
def filter_greater_than(self, query, value):
"""
Find all datetimes which fall after the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
time = make_utc(localtime(self.config, time))
return query.filter(self.column >= time)
def filter_greater_equal(self, query, value):
"""
Find all datetimes which fall on or after the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date, datetime.time(0))
time = make_utc(localtime(self.config, time))
return query.filter(self.column >= time)
def filter_less_than(self, query, value):
"""
Find all datetimes which fall before the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date, datetime.time(0))
time = make_utc(localtime(self.config, time))
return query.filter(self.column < time)
def filter_less_equal(self, query, value):
"""
Find all datetimes which fall on or before the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
time = make_utc(localtime(self.config, time))
return query.filter(self.column < time)
class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter):
"""
SQLAlchemy filter for *local* datetime values. This assumes datetime
values in the database are for local timezone, as opposed to UTC.
"""
def filter_equal(self, query, value):
"""
Find all dateimes which fall on the given date.
"""
date = self.make_date(value)
if not date:
return query
start = datetime.datetime.combine(date, datetime.time(0))
start = localtime(self.config, start, tzinfo=False)
stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
stop = localtime(self.config, stop, tzinfo=False)
return query.filter(self.column >= start)\
.filter(self.column < stop)
def filter_not_equal(self, query, value):
"""
Find all dateimes which do *not* fall on the given date.
"""
date = self.make_date(value)
if not date:
return query
start = datetime.datetime.combine(date, datetime.time(0))
start = localtime(self.config, start, tzinfo=False)
stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
stop = localtime(self.config, stop, tzinfo=False)
return query.filter(sa.or_(
self.column < start,
self.column <= stop))
def filter_greater_than(self, query, value):
"""
Find all datetimes which fall after the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
time = localtime(self.config, time, tzinfo=False)
return query.filter(self.column >= time)
def filter_greater_equal(self, query, value):
"""
Find all datetimes which fall on or after the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date, datetime.time(0))
time = localtime(self.config, time, tzinfo=False)
return query.filter(self.column >= time)
def filter_less_than(self, query, value):
"""
Find all datetimes which fall before the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date, datetime.time(0))
time = localtime(self.config, time, tzinfo=False)
return query.filter(self.column < time)
def filter_less_equal(self, query, value):
"""
Find all datetimes which fall on or before the given date.
"""
date = self.make_date(value)
if not date:
return query
time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0))
time = localtime(self.config, time, tzinfo=False)
return query.filter(self.column < time)
class AlchemyGPCFilter(AlchemyGridFilter):
"""
GPC filter for SQLAlchemy.
"""
default_verbs = ['equal', 'not_equal']
def filter_equal(self, query, value):
"""
Filter data with an equal ('=') query.
"""
if value is None or value == '':
return query
try:
return query.filter(self.column.in_((
GPC(value),
GPC(value, calc_check_digit='upc'))))
except ValueError:
return query
def filter_not_equal(self, query, value):
"""
Filter data with a not eqaul ('!=') query.
"""
if value is None or value == '':
return query
# When saying something is 'not equal' to something else, we must also
# include things which are nothing at all, in our result set.
try:
return query.filter(sa.or_(
~self.column.in_((
GPC(value),
GPC(value, calc_check_digit='upc'))),
self.column == None))
except ValueError:
return query
class GridFilterSet(OrderedDict):
"""
Collection class for :class:`GridFilter` instances.
"""
class GridFiltersForm(forms.Form):
"""
Form for grid filters.
"""
def __init__(self, filters, **kwargs):
self.filters = filters
if 'schema' not in kwargs:
schema = colander.Schema()
for key, filtr in self.filters.items():
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
kwargs['schema'] = schema
super(GridFiltersForm, self).__init__(**kwargs)
def iter_filters(self):
return self.filters.values()
def filter_verb(self, filtr):
"""
Render the verb selection dropdown for the given filter.
"""
options = [tags.Option(filtr.verb_labels.get(v, "unknown verb '{}'".format(v)), v)
for v in filtr.verbs]
hide_values = [v for v in filtr.valueless_verbs
if v in filtr.verbs]
return tags.select('{}.verb'.format(filtr.key), filtr.verb, options, **{
'class_': 'verb',
'data-hide-value-for': ' '.join(hide_values)})
def filter_value(self, filtr, **kwargs):
"""
Render the value input element(s) for the filter.
"""
style = 'display: none;' if filtr.verb in filtr.valueless_verbs else None
return HTML.tag('div', class_='value', style=style,
c=[filtr.render_value(**kwargs)])