Add support for "new-style grids" and "model master views".

Finally, an API that makes some sense...  We don't yet have feature parity
with the old-style grids and CRUD views, but this is already a significant
improvement to the design.  Still needs a lot of docs though...
This commit is contained in:
Lance Edgar 2015-07-29 11:09:38 -05:00
parent 62b7194c21
commit 585eb09bec
26 changed files with 2296 additions and 94 deletions

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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/>.
#
################################################################################
"""
Grids and Friends
"""
from . import filters
from .core import Grid, GridColumn, GridAction
from .alchemy import AlchemyGrid

View file

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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/>.
#
################################################################################
"""
FormAlchemy Grid Classes
"""
from __future__ import unicode_literals
import logging
import sqlalchemy as sa
from sqlalchemy import orm
import formalchemy
from webhelpers import paginate
from tailbone.db import Session
from tailbone.newgrids import Grid, GridColumn, filters
log = logging.getLogger(__name__)
class AlchemyGrid(Grid):
"""
Grid class for use with SQLAlchemy data models.
Note that this is partially just a wrapper around the FormAlchemy grid, and
that you may use this in much the same way, e.g.::
grid = AlchemyGrid(...)
grid.configure(
include=[
field1,
field2,
])
grid.append(field3)
del grid.field1
grid.field2.set(renderer=SomeFieldRenderer)
"""
def __init__(self, *args, **kwargs):
super(AlchemyGrid, self).__init__(*args, **kwargs)
self._fa_grid = formalchemy.Grid(self.model_class, session=Session(),
request=self.request)
def __delattr__(self, attr):
delattr(self._fa_grid, attr)
def __getattr__(self, attr):
return getattr(self._fa_grid, attr)
def default_filters(self):
"""
SQLAlchemy grids are able to provide a default set of filters based on
the column properties mapped to the model class.
"""
filtrs = filters.GridFilterSet()
mapper = orm.class_mapper(self.model_class)
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty) and prop.key != 'uuid':
filtrs[prop.key] = self.make_filter(prop)
return filtrs
def make_filter(self, model_property, **kwargs):
"""
Make a filter suitable for use with the given model property.
"""
if len(model_property.columns) > 1:
log.debug("ignoring secondary columns for sake of type detection")
coltype = model_property.columns[0].type
factory = filters.AlchemyGridFilter
if isinstance(coltype, sa.String):
factory = filters.AlchemyStringFilter
elif isinstance(coltype, sa.Numeric):
factory = filters.AlchemyNumericFilter
kwargs['model_property'] = model_property
return factory(model_property.key, **kwargs)
def iter_filters(self):
"""
Iterate over the grid's complete set of filters.
"""
return self.filters.itervalues()
def make_sorters(self, sorters):
"""
Returns a mapping of sort options for the grid. Keyword args override
the defaults, which are obtained via the SQLAlchemy ORM.
"""
sorters, updates = {}, sorters
mapper = orm.class_mapper(self.model_class)
for key, column in mapper.columns.items():
if key != 'uuid':
sorters[key] = self.make_sorter(column)
if updates:
sorters.update(updates)
return sorters
def make_sorter(self, field):
"""
Returns a function suitable for a sort map callable, with typical logic
built in for sorting applied to ``field``.
"""
return lambda q, d: q.order_by(getattr(field, d)())
def load_settings(self):
"""
When a SQLAlchemy grid loads its settings, it must update the
underlying FormAlchemy grid instance with the final (filtered/etc.)
data set.
"""
super(AlchemyGrid, self).load_settings()
self._fa_grid.rebind(self.make_visible_data(), session=Session(),
request=self.request)
def sort_data(self, query):
"""
Sort the given query according to current settings, and return the result.
"""
# Cannot sort unless we know which column to sort by.
if not self.sortkey:
return query
# Cannot sort unless we have a sort function.
sortfunc = self.sorters.get(self.sortkey)
if not sortfunc:
return query
# We can provide a default sort direction though.
sortdir = getattr(self, 'sortdir', 'asc')
return sortfunc(query, sortdir)
def paginate_data(self, query):
"""
Paginate the given data set according to current settings, and return
the result.
"""
return paginate.Page(
query, item_count=query.count(),
items_per_page=self.pagesize, page=self.page,
url=paginate.PageURL_WebOb(self.request))
def iter_visible_columns(self):
"""
Returns an iterator for all currently-visible columns.
"""
for field in self._fa_grid.render_fields.itervalues():
column = GridColumn()
column.field = field
column.key = field.name
column.label = field.label()
yield column
def iter_rows(self):
for row in self._fa_grid.rows:
self._fa_grid._set_active(row, orm.object_session(row))
yield row
def render_cell(self, row, column):
return column.field.render_readonly()

592
tailbone/newgrids/core.py Normal file
View file

@ -0,0 +1,592 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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/>.
#
################################################################################
"""
Core Grid Classes
"""
from __future__ import unicode_literals
from pyramid.renderers import render
from webhelpers.html import HTML, tags
from webhelpers.html.builder import format_attrs
from tailbone.newgrids import filters
class Grid(object):
"""
Core grid class. In sore need of documentation.
"""
def __init__(self, key, request, columns=[], data=[], main_actions=[], more_actions=[],
filterable=False, filters={},
sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
pageable=False, default_pagesize=20, default_page=1,
width='auto', checkboxes=False, **kwargs):
self.key = key
self.request = request
self.columns = columns
self.data = data
self.main_actions = main_actions
self.more_actions = more_actions
# Set extra attributes first, in case other init logic depends on any
# of them (i.e. in subclasses).
for kw, val in kwargs.iteritems():
setattr(self, kw, val)
self.filterable = filterable
if self.filterable:
self.filters = self.make_filters(filters)
self.sortable = sortable
if self.sortable:
self.sorters = self.make_sorters(sorters)
self.default_sortkey = default_sortkey
self.default_sortdir = default_sortdir
self.pageable = pageable
if self.pageable:
self.default_pagesize = default_pagesize
self.default_page = default_page
self.width = width
self.checkboxes = checkboxes
def get_default_filters(self):
"""
Returns the default set of filters provided by the grid.
"""
if hasattr(self, 'default_filters'):
if callable(self.default_filters):
return self.default_filters()
return self.default_filters
return filters.GridFilterSet()
def make_filters(self, filters=None):
"""
Returns an initial set of filters which will be available to the grid.
The grid itself may or may not provide some default filters, and the
``filters`` kwarg may contain additions and/or overrides.
"""
filters, updates = self.get_default_filters(), filters
if updates:
filters.update(updates)
return filters
def iter_filters(self):
"""
Iterate over all filters available to the grid.
"""
return self.filters.itervalues()
def iter_active_filters(self):
"""
Iterate over all *active* filters for the grid. Whether a filter is
active is determined by current grid settings.
"""
for filtr in self.iter_filters():
if filtr.active:
yield filtr
def has_active_filters(self):
"""
Returns boolean indicating whether the grid contains any *active*
filters, according to current settings.
"""
for filtr in self.iter_active_filters():
return True
return False
def make_sorters(self, sorters=None):
"""
Returns an initial set of sorters which will be available to the grid.
The grid itself may or may not provide some default sorters, and the
``sorters`` kwarg may contain additions and/or overrides.
"""
sorters, updates = {}, sorters
if updates:
sorters.update(updates)
return sorters
def load_settings(self, store=True):
"""
Load current/effective settings for the grid, from the request query
string and/or session storage. If ``store`` is true, then once
settings have been fully read, they are stored in current session for
next time. Finally, various instance attributes of the grid and its
filters are updated in-place to reflect the settings; this is so code
needn't access the settings dict directly, but the more Pythonic
instance attributes.
"""
# Initial settings come from class defaults.
settings = {
'sortkey': self.default_sortkey,
'sortdir': self.default_sortdir,
'pagesize': self.default_pagesize,
'page': self.default_page,
}
for filtr in self.iter_filters():
settings['filter.{0}.active'.format(filtr.key)] = filtr.default_active
settings['filter.{0}.verb'.format(filtr.key)] = filtr.default_verb
settings['filter.{0}.value'.format(filtr.key)] = filtr.default_value
# If request has filter settings, grab those, then grab sort/pager
# settings from request or session.
if self.request_has_settings('filter'):
self.update_filter_settings(settings, 'request')
if self.request_has_settings('sort'):
self.update_sort_settings(settings, 'request')
else:
self.update_sort_settings(settings, 'session')
self.update_page_settings(settings)
# If request has no filter settings but does have sort settings, grab
# those, then grab filter settings from session, then grab pager
# settings from request or session.
elif self.request_has_settings('sort'):
self.update_sort_settings(settings, 'request')
self.update_filter_settings(settings, 'session')
self.update_page_settings(settings)
# NOTE: These next two are functionally equivalent, but are kept
# separate to maintain the narrative...
# If request has no filter/sort settings but does have pager settings,
# grab those, then grab filter/sort settings from session.
elif self.request_has_settings('page'):
self.update_page_settings(settings)
self.update_filter_settings(settings, 'session')
self.update_sort_settings(settings, 'session')
# If request has no settings, grab all from session.
elif self.session_has_settings():
self.update_filter_settings(settings, 'session')
self.update_sort_settings(settings, 'session')
self.update_page_settings(settings)
# If no settings were found in request or session, don't store result.
else:
store = False
# Maybe store settings for next time.
if store:
self.store_settings(settings)
# Update ourself and our filters, to reflect settings.
if self.filterable:
for filtr in self.iter_filters():
filtr.active = settings['filter.{0}.active'.format(filtr.key)]
filtr.verb = settings['filter.{0}.verb'.format(filtr.key)]
filtr.value = settings['filter.{0}.value'.format(filtr.key)]
if self.sortable:
self.sortkey = settings['sortkey']
self.sortdir = settings['sortdir']
if self.pageable:
self.pagesize = settings['pagesize']
self.page = settings['page']
def request_has_settings(self, type_):
"""
Determine if the current request (GET query string) contains any
filter/sort settings for the grid.
"""
if type_ == 'filter':
for filtr in self.iter_filters():
if filtr.key in self.request.GET:
return True
if 'filter' in self.request.GET: # user may be applying empty filters
return True
elif type_ == 'sort':
for key in ['sortkey', 'sortdir']:
if key in self.request.GET:
return True
elif type_ == 'page':
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
return False
def session_has_settings(self):
"""
Determine if the current session contains any settings for the grid.
"""
# Session should have all or nothing, so just check one key.
return 'grid.{0}.sortkey'.format(self.key) in self.request.session
def get_setting(self, source, settings, key, coerce=lambda v: v, default=None):
"""
Get the effective value for a particular setting, preferring ``source``
but falling back to existing ``settings`` and finally the ``default``.
"""
if source not in ('request', 'session'):
raise ValueError("Invalid source identifier: {0}".format(repr(source)))
# If source is query string, try that first.
if source == 'request':
value = self.request.GET.get(key)
if value is not None:
try:
value = coerce(value)
except ValueError:
pass
else:
return value
# Or, if source is session, try that first.
else:
value = self.request.session.get('grid.{0}.{1}'.format(self.key, key))
if value is not None:
return value
# If source had nothing, try default/existing settings.
value = settings.get(key)
if value is not None:
try:
value = coerce(value)
except ValueError:
pass
else:
return value
# Okay then, default it is.
return default
def update_filter_settings(self, settings, source):
"""
Updates a settings dictionary according to filter settings data found
in either the GET query string, or session storage.
:param settings: Dictionary of initial settings, which is to be updated.
:param source: String identifying the source to consult for settings
data. Must be one of: ``('request', 'session')``.
"""
if not self.filterable:
return
for filtr in self.iter_filters():
prefix = 'filter.{0}'.format(filtr.key)
if source == 'request':
# consider filter active if query string contains a value for it
settings['{0}.active'.format(prefix)] = filtr.key in self.request.GET
settings['{0}.verb'.format(prefix)] = self.get_setting(
source, settings, '{0}.verb'.format(filtr.key), default='')
settings['{0}.value'.format(prefix)] = self.get_setting(
source, settings, filtr.key, default='')
else: # source = session
settings['{0}.active'.format(prefix)] = self.get_setting(
source, settings, '{0}.active'.format(prefix), default=False)
settings['{0}.verb'.format(prefix)] = self.get_setting(
source, settings, '{0}.verb'.format(prefix), default='')
settings['{0}.value'.format(prefix)] = self.get_setting(
source, settings, '{0}.value'.format(prefix), default='')
def update_sort_settings(self, settings, source):
"""
Updates a settings dictionary according to sort settings data found in
either the GET query string, or session storage.
:param settings: Dictionary of initial settings, which is to be updated.
:param source: String identifying the source to consult for settings
data. Must be one of: ``('request', 'session')``.
"""
if not self.sortable:
return
settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
def update_page_settings(self, settings):
"""
Updates a settings dictionary according to pager settings data found in
either the GET query string, or session storage.
Note that due to how the actual pager functions, the effective settings
will often come from *both* the request and session. This is so that
e.g. the page size will remain constant (coming from the session) while
the user jumps between pages (which only provides the single setting).
:param settings: Dictionary of initial settings, which is to be updated.
"""
if not self.pageable:
return
pagesize = self.request.GET.get('pagesize')
if pagesize is not None:
if pagesize.isdigit():
settings['pagesize'] = int(pagesize)
else:
pagesize = self.request.session.get('grid.{0}.pagesize'.format(self.key))
if pagesize is not None:
settings['pagesize'] = pagesize
page = self.request.GET.get('page')
if page is not None:
if page.isdigit():
settings['page'] = page
else:
page = self.request.session.get('grid.{0}.page'.format(self.key))
if page is not None:
settings['page'] = page
def store_settings(self, settings):
"""
Store settings in current session, for next time.
"""
def store(key):
self.request.session['grid.{0}.{1}'.format(self.key, key)] = settings[key]
if self.filterable:
for filtr in self.iter_filters():
store('filter.{0}.active'.format(filtr.key))
store('filter.{0}.verb'.format(filtr.key))
store('filter.{0}.value'.format(filtr.key))
if self.sortable:
store('sortkey')
store('sortdir')
if self.pageable:
store('pagesize')
store('page')
def filter_data(self, data):
"""
Filter and return the given data set, according to current settings.
"""
for filtr in self.iter_active_filters():
data = filtr.filter(data)
return data
def sort_data(self, data):
"""
Sort the given data set according to current settings, and return the
result. Note that the default implementation does nothing.
"""
return data
def paginate_data(self, data):
"""
Paginate the given data set according to current settings, and return
the result. Note that the default implementation does nothing.
"""
return data
def make_visible_data(self):
"""
Apply various settings to the raw data set, to produce a final data
set. This will page / sort / filter as necessary, according to the
grid's defaults and the current request etc.
"""
data = self.data
if self.filterable:
data = self.filter_data(data)
if self.sortable:
data = self.sort_data(data)
if self.pageable:
self.pager = self.paginate_data(data)
data = self.pager
return data
def render_complete(self, template='/newgrids/complete.mako', **kwargs):
"""
Render the complete grid, including filters.
"""
kwargs['grid'] = self
return render(template, kwargs)
def render_grid(self, template='/newgrids/grid.mako', **kwargs):
"""
Render the grid to a Unicode string, using the specified template.
Addition kwargs are passed along as context to the template.
"""
kwargs['grid'] = self
kwargs['format_attrs'] = format_attrs
return render(template, kwargs)
def render_filters(self, template='/newgrids/filters.mako', **kwargs):
"""
Render the filters to a Unicode string, using the specified template.
Additional kwargs are passed along as context to the template.
"""
# Provide default data to filters form, so renderer can do some of the
# work for us.
data = {}
for filtr in self.iter_active_filters():
data['{0}.active'.format(filtr.key)] = filtr.active
data['{0}.verb'.format(filtr.key)] = filtr.verb
data[filtr.key] = filtr.value
form = filters.GridFiltersForm(self.request, self.filters, defaults=data)
kwargs['grid'] = self
kwargs['form'] = filters.GridFiltersFormRenderer(form)
return render(template, kwargs)
def get_div_attrs(self):
"""
Returns a properly-formatted set of attributes which will be applied to
the parent ``<div>`` element which contains the grid, when the grid is
rendered.
"""
classes = ['newgrid']
if self.width == 'full':
classes.append('full')
return {'class_': ' '.join(classes),
'data-url': self.request.current_route_url(_query=None),
'data-permalink': self.request.current_route_url()}
def iter_visible_columns(self):
"""
Returns an iterator for all currently-visible columns.
"""
return iter(self.columns)
def column_header(self, column):
"""
Render a header (``<th>`` element) for a grid column.
"""
kwargs = {'c': column.label}
if self.sortable and column.key in self.sorters:
if column.key == self.sortkey:
kwargs['class_'] = 'sortable sorted {0}'.format(self.sortdir)
else:
kwargs['class_'] = 'sortable'
kwargs['data-sortkey'] = column.key
kwargs['c'] = tags.link_to(column.label, '#')
return HTML.tag('th', **kwargs)
@property
def show_actions_column(self):
"""
Whether or not an "Actions" column should be rendered for the grid.
"""
return bool(self.main_actions or self.more_actions)
def render_actions(self, row):
"""
Returns the rendered contents of the 'actions' column for a given row.
"""
main_actions = filter(None, [self.render_action(a, row) for a in self.main_actions])
more_actions = filter(None, [self.render_action(a, row) for a in self.more_actions])
if more_actions:
icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
link = tags.link_to("More" + icon, '#', class_='more')
main_actions.append(link + HTML.tag('div', class_='more', c=more_actions))
# main_actions.append(tags.link_to("More" + icon + HTML.literal('').join(more_actions), '#', class_='more'))
return HTML.literal('').join(main_actions)
def render_action(self, action, row):
"""
Renders an action menu item (link) for the given row.
"""
url = action.get_url(row)
if url:
if action.icon:
icon = HTML.tag('span', class_='ui-icon ui-icon-{0}'.format(action.icon))
return tags.link_to(icon + action.label, url)
return tags.link_to(action.label, url)
def iter_rows(self):
return []
def get_row_attrs(self, row, i):
"""
Returns a properly-formatted set of attributes which will be applied to
the ``<tr>`` element for the given row. Note that ``i`` will be a
1-based index value for the row within its table. The meaning of
``row`` is basically not defined; it depends on the type of data the
grid deals with.
"""
# attrs = {'class_': self.get_row_class(row, i)}
# attrs = {}
# return format_attrs(**attrs)
return {}
# def get_row_class(self, row, i):
# class_ = self.default_row_class(row, i)
# if callable(self.extra_row_class):
# extra = self.extra_row_class(row, i)
# if extra:
# class_ = '{0} {1}'.format(class_, extra)
# return class_
# def checkbox(self, key):
# """
# Render a checkbox using the given key.
# """
# return tags.checkbox('checkbox-{0}-{1}'.format(self.key, key))
def get_cell_attrs(self, row, column):
"""
Returns a dictionary of HTML attributes which should be applied to the
``<td>`` element in which the given row and column "intersect".
"""
return {}
def render_cell(self, row, column):
return ''
def get_pagesize_options(self):
# TODO: Make configurable or something...
return [5, 10, 20, 50, 100]
class GridColumn(object):
"""
Simple class to represent a column displayed within a grid table.
.. attribute:: key
Key for the column, within the context of the grid.
.. attribute:: label
Human-facing label for the column, i.e. displayed in the header.
"""
key = None
label = None
class GridAction(object):
"""
Represents an action available to a grid. This is used to construct the
'actions' column when rendering the grid.
"""
def __init__(self, key, label=None, url='#', icon=None):
self.key = key
self.label = label or key.capitalize()
self.icon = icon
self.url = url
def get_url(self, row):
"""
Returns an action URL for the given row.
"""
if callable(self.url):
return self.url(row)
return self.url

View file

@ -0,0 +1,314 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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
import sqlalchemy as sa
from edbob.util import prettify
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED
from pyramid_simpleform import Form
from pyramid_simpleform.renderers import FormRenderer
from webhelpers.html import HTML, tags
class FilterRenderer(object):
"""
Base class for all filter renderers.
"""
def render(self, value=None, **kwargs):
"""
Render the filter input element(s) as HTML. Default implementation
uses a simple text input.
"""
name = self.filter.key
return tags.text(name, value=value, id='filter.{0}.value'.format(name))
class DefaultRenderer(FilterRenderer):
"""
Default / fallback renderer.
"""
class NumericRenderer(FilterRenderer):
"""
Input renderer for numeric fields.
"""
class GridFilter(object):
"""
Represents a filter available to a grid. This is used to construct the
'filters' section when rendering the index page template.
"""
verbmap = {
'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",
'contains': "contains",
'does_not_contain': "does not contain",
}
def __init__(self, key, label=None, verbs=None, renderer=None,
default_active=False, default_verb=None, default_value=None):
self.key = key
self.label = label or prettify(key)
self.verbs = verbs or self.default_verbs()
self.renderer = renderer or DefaultRenderer()
self.renderer.filter = self
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
def __repr__(self):
return "GridFilter({0})".format(repr(self.key))
def default_verbs(self):
"""
Returns the set of verbs which will be used by default, i.e. unless
overridden by constructor args etc.
"""
return ['equal', 'not_equal', 'is_null', 'is_not_null']
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 render(self, **kwargs):
kwargs['filter'] = self
return self.renderer.render(**kwargs)
class AlchemyGridFilter(GridFilter):
"""
Base class for SQLAlchemy grid filters.
"""
def __init__(self, *args, **kwargs):
self.model_property = kwargs.pop('model_property')
self.model_class = self.model_property.parent.class_
self.model_column = getattr(self.model_class, self.model_property.key)
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.model_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.model_column == None,
self.model_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.model_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.model_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.model_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.model_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.model_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.model_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']
def filter_contains(self, query, value):
"""
Filter data with a full 'ILIKE' query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column.ilike('%{0}%'.format(value)))
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.model_column == None,
~self.model_column.ilike('%{0}%'.format(value)),
))
class AlchemyNumericFilter(AlchemyGridFilter):
"""
Numeric filter for SQLAlchemy.
"""
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 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.verbmap.get(v, "unknown verb '{0}'".format(v)))
for v in filtr.verbs]
return self.select('{0}.verb'.format(filtr.key), options, class_='verb')
def filter_value(self, filtr):
"""
Render the value input element(s) for the filter.
"""
# TODO: This surely needs some work..?
return HTML.tag('div', class_='value', c=filtr.render(value=self.value(filtr.key)))