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:
parent
62b7194c21
commit
585eb09bec
26 changed files with 2296 additions and 94 deletions
29
tailbone/newgrids/__init__.py
Normal file
29
tailbone/newgrids/__init__.py
Normal 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
|
183
tailbone/newgrids/alchemy.py
Normal file
183
tailbone/newgrids/alchemy.py
Normal 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
592
tailbone/newgrids/core.py
Normal 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
|
314
tailbone/newgrids/filters.py
Normal file
314
tailbone/newgrids/filters.py
Normal 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)))
|
Loading…
Add table
Add a link
Reference in a new issue