tailbone/tailbone/newgrids/filters.py
2016-11-10 23:13:21 -06:00

596 lines
18 KiB
Python

# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero 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 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
from pyramid_simpleform import Form
from pyramid_simpleform.renderers import FormRenderer
from webhelpers.html import HTML, tags
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):
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, choices):
self.choices = choices
def render(self, value=None, **kwargs):
return tags.select(self.name, [value], self.choices, **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.choices = [(k, enum[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_renderer=None,
default_active=False, default_verb=None, default_value=None, **kwargs):
self.key = key
self.label = label or prettify(key)
self.verbs = verbs or self.get_default_verbs()
self.set_value_renderer(value_renderer or self.value_renderer_factory)
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
for key, value in kwargs.iteritems():
setattr(self, key, value)
def __repr__(self):
return "GridFilter({0})".format(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 = value if value is not UNSPECIFIED else self.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 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 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 == 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 != 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 > 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 >= 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 < 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 <= 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('%{0}%'.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('%{0}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter):
"""
Numeric filter for SQLAlchemy.
"""
value_renderer_factory = NumericValueRenderer
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']
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 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(Form):
"""
Form for grid filters.
"""
def __init__(self, request, filters, *args, **kwargs):
super(GridFiltersForm, self).__init__(request, *args, **kwargs)
self.filters = filters
def iter_filters(self):
return self.filters.itervalues()
class GridFiltersFormRenderer(FormRenderer):
"""
Renderer for :class:`GridFiltersForm` instances.
"""
@property
def filters(self):
return self.form.filters
def iter_filters(self):
return self.form.iter_filters()
def tag(self, *args, **kwargs):
"""
Convenience method which passes all args to the
:func:`webhelpers:webhelpers.HTML.tag()` function.
"""
return HTML.tag(*args, **kwargs)
# TODO: This seems hacky..?
def checkbox(self, name, checked=None, **kwargs):
"""
Custom checkbox implementation.
"""
if name.endswith('-active'):
return tags.checkbox(name, checked=checked, **kwargs)
if checked is None:
checked = False
return super(GridFiltersFormRenderer, self).checkbox(name, checked=checked, **kwargs)
def filter_verb(self, filtr):
"""
Render the verb selection dropdown for the given filter.
"""
options = [(v, filtr.verb_labels.get(v, "unknown verb '{0}'".format(v)))
for v in filtr.verbs]
hide_values = [v for v in filtr.valueless_verbs
if v in filtr.verbs]
return self.select('{0}.verb'.format(filtr.key), 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))