tailbone/tailbone/newgrids/core.py
2017-07-11 10:57:35 -05:00

733 lines
27 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 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/>.
#
################################################################################
"""
Core Grid Classes
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db.api import get_setting, save_setting
from rattail.util import prettify
from pyramid.renderers import render
from webhelpers2.html import HTML, tags
from tailbone.db import Session
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=[],
joiners={}, filterable=False, filters={},
sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
pageable=False, default_pagesize=20, default_page=1,
width='auto', checkboxes=False, row_attrs={}, cell_attrs={},
delete_speedbump=False, **kwargs):
self.key = key
self.request = request
self.columns = columns
self.data = data
self.main_actions = main_actions
self.more_actions = more_actions
self.joiners = joiners or {} # use new/different empty dict for each instance
self.delete_speedbump = delete_speedbump
# 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
self.row_attrs = row_attrs or {}
self.cell_attrs = cell_attrs
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 six.itervalues(self.filters)
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 make_sorter(self, key, foldcase=False):
"""
Returns a function suitable for a sort map callable, with typical logic
built in for sorting a data set comprised of dicts, on the given key.
"""
if foldcase:
keyfunc = lambda v: v[key].lower()
else:
keyfunc = lambda v: v[key]
return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
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 = {}
if self.sortable:
settings['sortkey'] = self.default_sortkey
settings['sortdir'] = self.default_sortdir
if self.pageable:
settings['pagesize'] = self.default_pagesize
settings['page'] = self.default_page
if self.filterable:
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 user has default settings on file, apply those first.
if self.user_has_defaults():
self.apply_user_defaults(settings)
# If request contains instruction to reset to default filters, then we
# can skip the rest of the request/session checks.
if self.request.GET.get('reset-to-default-filters') == 'true':
pass
# If request has filter settings, grab those, then grab sort/pager
# settings from request or session.
elif self.filterable and 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.persist_settings(settings, 'session')
# If request contained instruction to save current settings as defaults
# for the current user, then do that.
if self.request.GET.get('save-current-filters-as-defaults') == 'true':
self.persist_settings(settings, 'defaults')
# 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 user_has_defaults(self):
"""
Check to see if the current user has default settings on file for this grid.
"""
user = self.request.user
if not user:
return False
# NOTE: we used to leverage `self.session` here, but sometimes we might
# be showing a grid of data from another system...so always use
# Tailbone Session now, for the settings. hopefully that didn't break
# anything...
session = Session()
if user not in session:
user = session.merge(user)
# User defaults should have all or nothing, so just check one key.
key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
return get_setting(session, key) is not None
def apply_user_defaults(self, settings):
"""
Update the given settings dict with user defaults, if any exist.
"""
def merge(key, coerce=lambda v: v):
skey = 'tailbone.{0}.grid.{1}.{2}'.format(self.request.user.uuid, self.key, key)
value = get_setting(Session(), skey)
settings[key] = coerce(value)
if self.filterable:
for filtr in self.iter_filters():
merge('filter.{0}.active'.format(filtr.key), lambda v: v == 'true')
merge('filter.{0}.verb'.format(filtr.key))
merge('filter.{0}.value'.format(filtr.key))
if self.sortable:
merge('sortkey')
merge('sortdir')
if self.pageable:
merge('pagesize', int)
merge('page', int)
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 a few keys which
# should be guaranteed present if anything has been stashed
for key in ['page', 'sortkey']:
if 'grid.{}.{}'.format(self.key, key) in self.request.session:
return True
return False
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 coerce(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),
coerce=lambda v: unicode(v).lower() == 'true', 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 persist_settings(self, settings, to='session'):
"""
Persist the given settings in some way, as defined by ``func``.
"""
def persist(key, value=lambda k: settings[k]):
if to == 'defaults':
skey = 'tailbone.{0}.grid.{1}.{2}'.format(self.request.user.uuid, self.key, key)
save_setting(Session(), skey, value(key))
else: # to == session
skey = 'grid.{0}.{1}'.format(self.key, key)
self.request.session[skey] = value(key)
if self.filterable:
for filtr in self.iter_filters():
persist('filter.{0}.active'.format(filtr.key), value=lambda k: unicode(settings[k]).lower())
persist('filter.{0}.verb'.format(filtr.key))
persist('filter.{0}.value'.format(filtr.key))
if self.sortable:
persist('sortkey')
persist('sortdir')
if self.pageable:
persist('pagesize')
persist('page')
def filter_data(self, data):
"""
Filter and return the given data set, according to current settings.
"""
for filtr in self.iter_active_filters():
if filtr.key in self.joiners and filtr.key not in self.joined:
data = self.joiners[filtr.key](data)
self.joined.add(filtr.key)
data = filtr.filter(data)
return data
def sort_data(self, data):
"""
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 data
# Cannot sort unless we have a sort function.
sortfunc = self.sorters.get(self.sortkey)
if not sortfunc:
return data
# We can provide a default sort direction though.
sortdir = getattr(self, 'sortdir', 'asc')
if self.sortkey in self.joiners and self.sortkey not in self.joined:
data = self.joiners[self.sortkey](data)
self.joined.add(self.sortkey)
return sortfunc(data, sortdir)
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.
"""
self.joined = set()
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
kwargs.setdefault('allow_save_defaults', True)
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
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['request'] = self.request
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')
if self.checkboxes:
classes.append('selectable')
attrs = {'class_': ' '.join(classes),
'data-url': self.request.current_route_url(_query=None),
'data-permalink': self.request.current_route_url()}
if self.delete_speedbump:
attrs['data-delete-speedbump'] = 'true'
return attrs
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, '#', title=column.title)
elif column.title:
kwargs['c'] = HTML.tag('span', title=column.title, c=column.label)
kwargs['class_'] = '{} {}'.format(kwargs.get('class_', ''), column.key)
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, i):
"""
Returns the rendered contents of the 'actions' column for a given row.
"""
main_actions = filter(None, [self.render_action(a, row, i) for a in self.main_actions])
more_actions = filter(None, [self.render_action(a, row, i) 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))
return HTML.literal('').join(main_actions)
def render_action(self, action, row, i):
"""
Renders an action menu item (link) for the given row.
"""
url = action.get_url(row, i)
if url:
kwargs = {'class_': action.key, 'target': action.target}
if action.icon:
icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon))
return tags.link_to(icon + action.label, url, **kwargs)
return tags.link_to(action.label, url, **kwargs)
def iter_rows(self):
return self.make_visible_data()
def get_row_attrs(self, row, i):
"""
Returns a dict of HTML attributes which is to be applied to the row's
``<tr>`` element. 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.
"""
if callable(self.row_attrs):
return self.row_attrs(row, i)
return self.row_attrs
# 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 get_row_key(self, row):
raise NotImplementedError
def checkbox(self, row):
return True
def checked(self, row):
return False
def render_checkbox(self, row):
"""
Returns a boolean indicating whether ot not a checkbox should be
rendererd for the given row. Default implementation returns ``True``
in all cases.
"""
if not self.checkbox(row):
return ''
return tags.checkbox('checkbox-{0}-{1}'.format(self.key, self.get_row_key(row)),
checked=self.checked(row))
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".
"""
if callable(self.cell_attrs):
return self.cell_attrs(row, column)
return self.cell_attrs
def render_cell(self, row, column):
return column.render(row[column.key])
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.
"""
def __init__(self, key, label=None, title=None):
self.key = key
self.label = label or prettify(key)
self.title = title
def render(self, value):
"""
Render the given value, to be displayed within a grid cell.
"""
return unicode(value)
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, target=None):
self.key = key
self.label = label or prettify(key)
self.icon = icon
self.url = url
self.target = target
def get_url(self, row, i):
"""
Returns an action URL for the given row.
"""
if callable(self.url):
return self.url(row, i)
return self.url