diff --git a/setup.py b/setup.py
index a17d51ef..0a255743 100644
--- a/setup.py
+++ b/setup.py
@@ -96,6 +96,7 @@ requires = [
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers2', # 2.0
+ 'webhelpers2_grid', # 0.1
'WTForms', # 2.1
'zope.sqlalchemy', # 0.7
]
diff --git a/tailbone/grids3/__init__.py b/tailbone/grids3/__init__.py
new file mode 100644
index 00000000..6255f530
--- /dev/null
+++ b/tailbone/grids3/__init__.py
@@ -0,0 +1,33 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Grids and Friends
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from .core import Grid, GridAction
+from .mobile import MobileGrid
+
+# TODO
+from tailbone.newgrids import filters
diff --git a/tailbone/grids3/core.py b/tailbone/grids3/core.py
new file mode 100644
index 00000000..80c60011
--- /dev/null
+++ b/tailbone/grids3/core.py
@@ -0,0 +1,919 @@
+# -*- 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 .
+#
+################################################################################
+"""
+Core Grid Classes
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import six
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from rattail.db import api
+from rattail.db.types import GPCType
+from rattail.util import pretty_boolean, pretty_quantity
+
+import webhelpers2_grid
+from pyramid.renderers import render
+from webhelpers2.html import HTML, tags
+from paginate_sqlalchemy import SqlalchemyOrmPage
+
+from tailbone.db import Session
+from tailbone import newgrids
+from tailbone.newgrids import GridAction
+from tailbone.newgrids.alchemy import URLMaker
+from tailbone.util import raw_datetime
+
+
+class Grid(object):
+ """
+ Core grid class. In sore need of documentation.
+ """
+
+ def __init__(self, key, data, columns, request=None, mobile=False, model_class=None, enums={},
+ labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#',
+ joiners={}, filterable=False, filters={},
+ sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
+ pageable=False, default_pagesize=20, default_page=1,
+ checkboxes=False, main_actions=[], more_actions=[],
+ **kwargs):
+
+ self.key = key
+ self.data = data
+ self.columns = columns
+ self.request = request
+ self.mobile = mobile
+ self.model_class = model_class
+ self.enums = enums or {}
+
+ self.labels = labels or {}
+ self.renderers = renderers or {}
+ self.extra_row_class = extra_row_class
+ self.linked_columns = linked_columns or []
+ self.url = url
+ self.joiners = joiners or {}
+
+ self.filterable = filterable
+ self.filters = self.make_filters(filters)
+
+ self.sortable = sortable
+ self.sorters = self.make_sorters(sorters)
+ self.default_sortkey = default_sortkey
+ self.default_sortdir = default_sortdir
+
+ self.pageable = pageable
+ self.default_pagesize = default_pagesize
+ self.default_page = default_page
+
+ self.checkboxes = checkboxes
+ self.main_actions = main_actions
+ self.more_actions = more_actions
+
+ self._whgrid_kwargs = kwargs
+
+ def hide_column(self, key):
+ if key in self.columns:
+ self.columns.remove(key)
+
+ def set_label(self, key, label):
+ self.labels[key] = label
+ if key in self.filters:
+ self.filters[key].label = label
+
+ def set_link(self, key, link=True):
+ if link:
+ if key not in self.linked_columns:
+ self.linked_columns.append(key)
+ else: # unlink
+ if self.linked_columns and key in self.linked_columns:
+ self.linked_columns.remove(key)
+
+ def set_renderer(self, key, renderer):
+ # TODO: deprecate / remove "type" detection here
+ if renderer == 'boolean':
+ renderer = self.render_boolean
+ elif renderer == 'currency':
+ renderer = self.render_currency
+ elif renderer == 'datetime':
+ renderer = self.render_datetime
+ elif renderer == 'gpc':
+ renderer = self.render_gpc
+ elif renderer == 'quantity':
+ renderer = self.render_quantity
+ self.renderers[key] = renderer
+
+ def set_type(self, key, type_):
+ if type_ == 'boolean':
+ self.set_renderer(key, self.render_boolean)
+ elif type_ == 'currency':
+ self.set_renderer(key, self.render_currency)
+ elif type_ == 'datetime':
+ self.set_renderer(key, self.render_datetime)
+ elif type_ == 'enum':
+ self.set_renderer(key, self.render_enum)
+ elif type_ == 'gpc':
+ self.set_renderer(key, self.render_gpc)
+ elif type_ == 'quantity':
+ self.set_renderer(key, self.render_quantity)
+ else:
+ raise ValueError("Unsupported type for column '{}': {}".format(key, type_))
+
+ def set_enum(self, key, enum):
+ if enum:
+ self.enums[key] = enum
+ self.set_type(key, 'enum')
+ else:
+ self.enums.pop(key, None)
+
+ def render_boolean(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ return pretty_boolean(value)
+
+ def obtain_value(self, obj, column_name):
+ try:
+ return obj[column_name]
+ except TypeError:
+ return getattr(obj, column_name)
+
+ def render_currency(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ if value is None:
+ return ""
+ if value < 0:
+ return "(${:0,.2f})".format(0 - value)
+ return "${:0,.2f}".format(value)
+
+ def render_datetime(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ if value is None:
+ return ""
+ return raw_datetime(self.request.rattail_config, value)
+
+ def render_enum(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ if value is None:
+ return ""
+ enum = self.enums.get(column_name)
+ if enum and value in enum:
+ return six.text_type(enum[value])
+ return six.text_type(value)
+
+ def render_gpc(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ if value is None:
+ return ""
+ return value.pretty()
+
+ def render_quantity(self, obj, column_name):
+ value = self.obtain_value(obj, column_name)
+ return pretty_quantity(value)
+
+ def set_url(self, url):
+ self.url = url
+
+ def make_url(self, obj, i=None):
+ if callable(self.url):
+ return self.url(obj)
+ return self.url
+
+ def make_webhelpers_grid(self):
+ kwargs = dict(self._whgrid_kwargs)
+ kwargs['request'] = self.request
+ kwargs['mobile'] = self.mobile
+ kwargs['url'] = self.make_url
+
+ columns = list(self.columns)
+ column_labels = kwargs.setdefault('column_labels', {})
+ column_formats = kwargs.setdefault('column_formats', {})
+
+ for key, value in self.labels.items():
+ column_labels.setdefault(key, value)
+
+ if self.checkboxes:
+ columns.insert(0, 'checkbox')
+ column_labels['checkbox'] = tags.checkbox('check-all')
+ column_formats['checkbox'] = self.checkbox_column_format
+
+ if self.renderers:
+ kwargs['renderers'] = dict(self.renderers)
+ if self.extra_row_class:
+ kwargs['extra_record_class'] = self.extra_row_class
+ if self.linked_columns:
+ kwargs['linked_columns'] = list(self.linked_columns)
+
+ if self.main_actions or self.more_actions:
+ columns.append('actions')
+ column_formats['actions'] = self.actions_column_format
+
+ # TODO: pretty sure this factory doesn't serve all use cases yet?
+ factory = CustomWebhelpersGrid
+ # factory = webhelpers2_grid.Grid
+ if self.sortable:
+ # factory = CustomWebhelpersGrid
+ kwargs['order_column'] = self.sortkey
+ kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
+
+ grid = factory(self.make_visible_data(), columns, **kwargs)
+ if self.sortable:
+ grid.exclude_ordering = list([key for key in grid.exclude_ordering
+ if key not in self.sorters])
+ return grid
+
+ def checkbox_column_format(self, column_number, row_number, item):
+ return HTML.td(self.render_checkbox(item), class_='checkbox')
+
+ def actions_column_format(self, column_number, row_number, item):
+ return HTML.td(self.render_actions(item, row_number), class_='actions')
+
+ def render_grid(self, template='/grids3/grid.mako', **kwargs):
+ context = kwargs
+ context['grid'] = self
+ return render(template, context)
+
+ 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
+ filters = newgrids.filters.GridFilterSet()
+ if self.model_class:
+ mapper = orm.class_mapper(self.model_class)
+ for prop in mapper.iterate_properties:
+ if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
+ filters[prop.key] = self.make_filter(prop.key, prop.columns[0])
+ return filters
+
+ 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.
+ """
+ if filters:
+ return filters
+ return self.get_default_filters()
+
+ def make_filter(self, key, column, **kwargs):
+ """
+ Make a filter suitable for use with the given column.
+ """
+ factory = kwargs.pop('factory', None)
+ if not factory:
+ factory = newgrids.filters.AlchemyGridFilter
+ if isinstance(column.type, sa.String):
+ factory = newgrids.filters.AlchemyStringFilter
+ elif isinstance(column.type, sa.Numeric):
+ factory = newgrids.filters.AlchemyNumericFilter
+ elif isinstance(column.type, sa.Integer):
+ factory = newgrids.filters.AlchemyNumericFilter
+ elif isinstance(column.type, sa.Boolean):
+ # TODO: check column for nullable here?
+ factory = newgrids.filters.AlchemyNullableBooleanFilter
+ elif isinstance(column.type, sa.Date):
+ factory = newgrids.filters.AlchemyDateFilter
+ elif isinstance(column.type, sa.DateTime):
+ factory = newgrids.filters.AlchemyDateTimeFilter
+ elif isinstance(column.type, GPCType):
+ factory = newgrids.filters.AlchemyGPCFilter
+ return factory(key, column=column, config=self.request.rattail_config, **kwargs)
+
+ 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 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 self.model_class:
+ mapper = orm.class_mapper(self.model_class)
+ for prop in mapper.iterate_properties:
+ if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
+ sorters[prop.key] = self.make_sorter(prop)
+ if updates:
+ sorters.update(updates)
+ return sorters
+
+ def make_sorter(self, model_property):
+ """
+ Returns a function suitable for a sort map callable, with typical logic
+ built in for sorting applied to ``field``.
+ """
+ class_ = getattr(model_property, 'class_', self.model_class)
+ column = getattr(class_, model_property.key)
+ return lambda q, d: q.order_by(getattr(column, d)())
+
+ def make_simple_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 default settings
+ 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.{}.active'.format(filtr.key)] = filtr.default_active
+ settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
+ settings['filter.{}.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 to reflect settings
+ if self.filterable:
+ for filtr in self.iter_filters():
+ filtr.active = settings['filter.{}.active'.format(filtr.key)]
+ filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
+ filtr.value = settings['filter.{}.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 api.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, normalize=lambda v: v):
+ skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
+ value = api.get_setting(Session(), skey)
+ settings[key] = normalize(value)
+
+ if self.filterable:
+ for filtr in self.iter_filters():
+ merge('filter.{}.active'.format(filtr.key), lambda v: v == 'true')
+ merge('filter.{}.verb'.format(filtr.key))
+ merge('filter.{}.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, normalize=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: {}".format(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 = normalize(value)
+ except ValueError:
+ pass
+ else:
+ return value
+
+ # Or, if source is session, try that first.
+ else:
+ value = self.request.session.get('grid.{}.{}'.format(self.key, key))
+ if value is not None:
+ return normalize(value)
+
+ # If source had nothing, try default/existing settings.
+ value = settings.get(key)
+ if value is not None:
+ try:
+ value = normalize(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.{}'.format(filtr.key)
+
+ if source == 'request':
+ # consider filter active if query string contains a value for it
+ settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
+ settings['{}.verb'.format(prefix)] = self.get_setting(
+ source, settings, '{}.verb'.format(filtr.key), default='')
+ settings['{}.value'.format(prefix)] = self.get_setting(
+ source, settings, filtr.key, default='')
+
+ else: # source = session
+ settings['{}.active'.format(prefix)] = self.get_setting(
+ source, settings, '{}.active'.format(prefix),
+ normalize=lambda v: six.text_type(v).lower() == 'true', default=False)
+ settings['{}.verb'.format(prefix)] = self.get_setting(
+ source, settings, '{}.verb'.format(prefix), default='')
+ settings['{}.value'.format(prefix)] = self.get_setting(
+ source, settings, '{}.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.{}.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.{}.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.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
+ api.save_setting(Session(), skey, value(key))
+ else: # to == session
+ skey = 'grid.{}.{}'.format(self.key, key)
+ self.request.session[skey] = value(key)
+
+ if self.filterable:
+ for filtr in self.iter_filters():
+ persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower())
+ persist('filter.{}.verb'.format(filtr.key))
+ persist('filter.{}.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():
+
+ # apply filter to data but save reference to original; if data is a
+ # SQLAlchemy query and wasn't modified, we don't need to bother
+ # with the underlying join (if there is one)
+ original = data
+ data = filtr.filter(data)
+ if filtr.key in self.joiners and filtr.key not in self.joined and (
+ not isinstance(data, orm.Query) or data is not original):
+
+ # this filter requires a join; apply that
+ data = self.joiners[filtr.key](data)
+ self.joined.add(filtr.key)
+
+ 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.
+ """
+ if self.model_class:
+ return SqlalchemyOrmPage(data,
+ items_per_page=self.pagesize,
+ page=self.page,
+ url_maker=URLMaker(self.request))
+ 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.
+ """
+ context = kwargs
+ context['grid'] = self
+ context.setdefault('allow_save_defaults', True)
+ return render(template, context)
+
+ 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['{}.active'.format(filtr.key)] = filtr.active
+ data['{}.verb'.format(filtr.key)] = filtr.verb
+ data[filtr.key] = filtr.value
+
+ form = newgrids.filters.GridFiltersForm(self.request, self.filters, defaults=data)
+
+ kwargs['request'] = self.request
+ kwargs['grid'] = self
+ kwargs['form'] = newgrids.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 ``
`` 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 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 get_row_key(self, item):
+ """
+ Must return a unique key for the given data item's row.
+ """
+ mapper = orm.object_mapper(item)
+ if len(mapper.primary_key) == 1:
+ return getattr(item, mapper.primary_key[0].key)
+ raise NotImplementedError
+
+ def checkbox(self, item):
+ """
+ Returns boolean indicating whether a checkbox should be rendererd for
+ the given data item's row.
+ """
+ return True
+
+ def checked(self, item):
+ """
+ Returns boolean indicating whether the given item's row checkbox should
+ be checked, for initial page load.
+ """
+ return False
+
+ def render_checkbox(self, item):
+ """
+ Renders a checkbox cell for the given item, if applicable.
+ """
+ if not self.checkbox(item):
+ return ''
+ return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
+ checked=self.checked(item))
+
+ def get_pagesize_options(self):
+ # TODO: Make configurable or something...
+ return [5, 10, 20, 50, 100]
+
+
+class CustomWebhelpersGrid(webhelpers2_grid.Grid):
+ """
+ Implement column sorting links etc. for webhelpers2_grid
+ """
+
+ def __init__(self, itemlist, columns, **kwargs):
+ self.mobile = kwargs.pop('mobile', False)
+ self.renderers = kwargs.pop('renderers', {})
+ self.linked_columns = kwargs.pop('linked_columns', [])
+ self.extra_record_class = kwargs.pop('extra_record_class', None)
+ super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
+
+ def default_header_record_format(self, headers):
+ if self.mobile:
+ return HTML('')
+ return super(CustomWebhelpersGrid, self).default_header_record_format(headers)
+
+ def generate_header_link(self, column_number, column, label_text):
+
+ # display column header as simple no-op link; client-side JS takes care
+ # of the rest for us
+ label_text = tags.link_to(label_text, '#', data_sortkey=column)
+
+ # Is the current column the one we're ordering on?
+ if (column == self.order_column):
+ return self.default_header_ordered_column_format(column_number,
+ column,
+ label_text)
+ else:
+ return self.default_header_column_format(column_number, column,
+ label_text)
+
+ def default_record_format(self, i, record, columns):
+ if self.mobile:
+ return columns
+ kwargs = {
+ 'class_': self.get_record_class(i, record, columns),
+ }
+ if hasattr(record, 'uuid'):
+ kwargs['data_uuid'] = record.uuid
+ return HTML.tag('tr', columns, **kwargs)
+
+ def get_record_class(self, i, record, columns):
+ if i % 2 == 0:
+ cls = 'even r{}'.format(i)
+ else:
+ cls = 'odd r{}'.format(i)
+ if self.extra_record_class:
+ extra = self.extra_record_class(record, i)
+ if extra:
+ cls = '{} {}'.format(cls, extra)
+ return cls
+
+ def get_column_value(self, column_number, i, record, column_name):
+ if self.renderers and column_name in self.renderers:
+ return self.renderers[column_name](record, column_name)
+ try:
+ return record[column_name]
+ except TypeError:
+ return getattr(record, column_name)
+
+ def default_column_format(self, column_number, i, record, column_name):
+ value = self.get_column_value(column_number, i, record, column_name)
+ if self.mobile:
+ url = self.url_generator(record, i)
+ return HTML.tag('li', tags.link_to(value, url))
+ if self.linked_columns and column_name in self.linked_columns:
+ url = self.url_generator(record, i)
+ value = tags.link_to(value, url)
+ class_name = 'c{}'.format(column_number)
+ return HTML.tag('td', value, class_=class_name)
diff --git a/tailbone/grids3/mobile.py b/tailbone/grids3/mobile.py
new file mode 100644
index 00000000..b5f3cd66
--- /dev/null
+++ b/tailbone/grids3/mobile.py
@@ -0,0 +1,53 @@
+# -*- 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
.
+#
+################################################################################
+"""
+Mobile Grids
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from pyramid.renderers import render
+
+from tailbone.grids3 import Grid
+
+
+class MobileGrid(Grid):
+ """
+ Base class for all mobile grids
+ """
+
+ def render_filters(self, template='/mobile/newgrids/filters_simple.mako', **kwargs):
+ context = kwargs
+ context['request'] = self.request
+ context['grid'] = self
+ return render(template, context)
+
+ def render_grid(self, template='/mobile/newgrids/grid.mako', **kwargs):
+ context = kwargs
+ context['grid'] = self
+ return render(template, context)
+
+ def render_complete(self, template='/mobile/newgrids/complete.mako', **kwargs):
+ context = kwargs
+ context['grid'] = self
+ return render(template, context)
diff --git a/tailbone/newgrids/mobile.py b/tailbone/newgrids/mobile.py
index 776ce229..911a3c6d 100644
--- a/tailbone/newgrids/mobile.py
+++ b/tailbone/newgrids/mobile.py
@@ -42,13 +42,13 @@ class MobileGrid(AlchemyGrid):
kwargs = {'c': column.label}
return HTML.tag('th', **kwargs)
- def render_filters(self, template='/mobile/filters_simple.mako', **kwargs):
+ def render_filters(self, template='/mobile/newgrids/filters_simple.mako', **kwargs):
context = kwargs
context['request'] = self.request
context['grid'] = self
return render(template, context)
- def render_complete(self, template='/mobile/grid_complete.mako', **kwargs):
+ def render_complete(self, template='/mobile/newgrids/complete.mako', **kwargs):
context = kwargs
context['grid'] = self
return render(template, context)
diff --git a/tailbone/static/css/grids3.css b/tailbone/static/css/grids3.css
new file mode 100644
index 00000000..e425561a
--- /dev/null
+++ b/tailbone/static/css/grids3.css
@@ -0,0 +1,71 @@
+
+/********************************************************************************
+ * grids3.css
+ *
+ * Style tweaks for the new grids.
+ ********************************************************************************/
+
+
+/******************************
+ * thead
+ ******************************/
+
+.grid3 tr.header td {
+ border-right: 1px solid black;
+ border-bottom: 1px solid black;
+ font-weight: bold;
+ padding: 2px 3px;
+ text-align: center;
+}
+
+.grid3 tr.header a {
+ display: block;
+ padding-right: 18px;
+}
+
+.grid3 tr.header .asc,
+.grid3 tr.header .dsc {
+ background-position: right center;
+ background-repeat: no-repeat;
+}
+
+.grid3 tr.header .asc {
+ background-image: url(../img/sort_arrow_up.png);
+}
+
+.grid3 tr.header .dsc {
+ background-image: url(../img/sort_arrow_down.png);
+}
+
+
+/******************************
+ * tbody
+ ******************************/
+
+.grid3 tr.odd {
+ background-color: #e0e0e0;
+}
+
+.grid3 tr.even {
+ background-color: White;
+}
+
+/* this is needed only as override? */
+.newgrid.grid3 tbody tr:nth-child(odd) {
+ background-color: White;
+}
+.newgrid.grid3 tbody tr:nth-child(odd).hovering {
+ background-color: #bbbbbb;
+}
+
+.newgrid.grid3 tr:not(.header).notice.odd {
+ background-color: #fe8;
+}
+
+.newgrid.grid3 tr:not(.header).notice.even {
+ background-color: #fd6;
+}
+
+.newgrid.grid3 tr:not(.header).notice.hovering {
+ background-color: #ec7;
+}
diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js
index 9986ce97..65e8f7a7 100644
--- a/tailbone/static/js/jquery.ui.tailbone.js
+++ b/tailbone/static/js/jquery.ui.tailbone.js
@@ -117,17 +117,31 @@
});
// Refresh data when user clicks a sortable column header.
- this.element.on('click', 'thead th.sortable a', function() {
- var th = $(this).parent();
- var data = {
- sortkey: th.data('sortkey'),
- sortdir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
- page: 1,
- partial: true
- };
- that.refresh(data);
- return false;
- });
+ if (this.grid.hasClass('grid3')) {
+ this.element.on('click', 'tr.header a', function() {
+ var td = $(this).parent();
+ var data = {
+ sortkey: $(this).data('sortkey'),
+ sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
+ page: 1,
+ partial: true
+ };
+ that.refresh(data);
+ return false;
+ });
+ } else {
+ this.element.on('click', 'thead th.sortable a', function() {
+ var th = $(this).parent();
+ var data = {
+ sortkey: th.data('sortkey'),
+ sortdir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
+ page: 1,
+ partial: true
+ };
+ that.refresh(data);
+ return false;
+ });
+ }
// Refresh data when user chooses a new page size setting.
this.element.on('change', '.pager #pagesize', function() {
@@ -145,15 +159,39 @@
});
// Add hover highlight effect to grid rows during mouse-over.
- this.element.on('mouseenter', 'tbody tr', function() {
+ this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
$(this).addClass('hovering');
});
- this.element.on('mouseleave', 'tbody tr', function() {
+ this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
$(this).removeClass('hovering');
});
- // Do some extra stuff for grids with checkboxes.
- if (this.grid.hasClass('selectable')) {
+ // do some extra stuff for grids with checkboxes
+ if (this.grid.hasClass('grid3')) {
+
+ // (un-)check all rows when clicking check-all box in header
+ if (this.grid.find('tr.header td.checkbox input').length) {
+ this.element.on('click', 'tr.header td.checkbox input', function() {
+ var checked = $(this).prop('checked');
+ that.grid.find('tr:not(.header) td.checkbox input').prop('checked', checked);
+ });
+
+ }
+
+ // Select current row when clicked, unless clicking checkbox
+ // (since that already does select the row) or a link (since
+ // that does something completely different).
+ this.element.on('click', '.newgrid tr:not(.header) td.checkbox input', function(event) {
+ event.stopPropagation();
+ });
+ this.element.on('click', '.newgrid tr:not(.header) a', function(event) {
+ event.stopPropagation();
+ });
+ this.element.on('click', '.newgrid tr:not(.header)', function() {
+ $(this).find('td.checkbox input').click();
+ });
+
+ } else if (this.grid.hasClass('selectable')) { // pre-v3 newgrid.selectable
// (Un-)Check all rows when clicking check-all box in header.
this.element.on('click', 'thead th.checkbox input', function() {
diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js
index 588ac63b..753f10eb 100644
--- a/tailbone/static/js/tailbone.js
+++ b/tailbone/static/js/tailbone.js
@@ -323,18 +323,6 @@ $(function() {
});
});
-
- /*
- * TODO: this should be deprecated; for old grids only?
- * Add "check all" functionality to tables with checkboxes.
- */
- $('body').on('click', '.grid thead th.checkbox input[type="checkbox"]', function() {
- var table = $(this).parents('table:first');
- var checked = $(this).prop('checked');
- table.find('tbody tr').each(function() {
- $(this).find('td.checkbox input[type="checkbox"]').prop('checked', checked);
- });
- });
$('body').on('click', 'div.dialog button.close', function() {
var dialog = $(this).parents('div.dialog:first');
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 5b4c2e78..8694b421 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -1,4 +1,4 @@
-# -*- coding: utf-8 -*-
+# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
@@ -26,6 +26,8 @@ Event Subscribers
from __future__ import unicode_literals, absolute_import
+import six
+
import rattail
from rattail.db import model
from rattail.db.auth import has_permission
@@ -79,6 +81,7 @@ def before_render(event):
renderer_globals['rattail'] = rattail
renderer_globals['tailbone'] = tailbone
renderer_globals['enum'] = request.rattail_config.get_enum()
+ renderer_globals['six'] = six
def add_inbox_count(event):
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 98de14b2..6bc625d4 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -150,6 +150,7 @@
${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/newgrids.css'))}
+ ${h.stylesheet_link(request.static_url('tailbone:static/css/grids3.css'))}
%def>
<%def name="jquery_smoothness_theme()">
diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako
index b98e1a56..90aba61a 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -1,8 +1,8 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/index.mako" />
+## -*- coding: utf-8; -*-
+<%inherit file="/master2/index.mako" />
-<%def name="head_tags()">
- ${parent.head_tags()}
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako
index cf2d51e9..331f0456 100644
--- a/tailbone/templates/messages/inbox/index.mako
+++ b/tailbone/templates/messages/inbox/index.mako
@@ -3,8 +3,8 @@
<%def name="title()">Message Inbox%def>
-<%def name="head_tags()">
- ${parent.head_tags()}
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako
index dbc13fe3..7192f7c3 100644
--- a/tailbone/templates/messages/index.mako
+++ b/tailbone/templates/messages/index.mako
@@ -1,14 +1,14 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/index.mako" />
+## -*- coding: utf-8; -*-
+<%inherit file="/master2/index.mako" />
-<%def name="head_tags()">
- ${parent.head_tags()}
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
+%def>
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
-
%def>
<%def name="context_menu_items()">
diff --git a/tailbone/templates/mobile/newgrids/complete.mako b/tailbone/templates/mobile/newgrids/complete.mako
new file mode 100644
index 00000000..ebb58334
--- /dev/null
+++ b/tailbone/templates/mobile/newgrids/complete.mako
@@ -0,0 +1,7 @@
+## -*- coding: utf-8; -*-
+
+% if grid.filterable:
+ ${grid.render_filters()|n}
+% endif
+
+${grid.render_grid()|n}
diff --git a/tailbone/templates/mobile/filters_simple.mako b/tailbone/templates/mobile/newgrids/filters_simple.mako
similarity index 100%
rename from tailbone/templates/mobile/filters_simple.mako
rename to tailbone/templates/mobile/newgrids/filters_simple.mako
diff --git a/tailbone/templates/mobile/grid_complete.mako b/tailbone/templates/mobile/newgrids/grid.mako
similarity index 87%
rename from tailbone/templates/mobile/grid_complete.mako
rename to tailbone/templates/mobile/newgrids/grid.mako
index 6402d6f0..b7b029b5 100644
--- a/tailbone/templates/mobile/grid_complete.mako
+++ b/tailbone/templates/mobile/newgrids/grid.mako
@@ -1,13 +1,7 @@
## -*- coding: utf-8; -*-
-% if grid.filterable:
- ${grid.render_filters()|n}
-% endif
-
- % for obj in grid.iter_rows():
- - ${grid.listitem.render_readonly()}
- % endfor
+ ${grid.make_webhelpers_grid()}
##
diff --git a/tailbone/templates/newbatch/index.mako b/tailbone/templates/newbatch/index.mako
index 2380ecea..a0ccbd8f 100644
--- a/tailbone/templates/newbatch/index.mako
+++ b/tailbone/templates/newbatch/index.mako
@@ -1,3 +1,4 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/index.mako" />
+## -*- coding: utf-8; -*-
+<%inherit file="/master2/index.mako" />
+
${parent.body()}
diff --git a/tailbone/templates/principal/index.mako b/tailbone/templates/principal/index.mako
index 97c48995..79f5fa1c 100644
--- a/tailbone/templates/principal/index.mako
+++ b/tailbone/templates/principal/index.mako
@@ -1,5 +1,5 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/index.mako" />
+## -*- coding: utf-8; -*-
+<%inherit file="/master2/index.mako" />
<%def name="context_menu_items()">
${parent.context_menu_items()}
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index f241eefa..eb24cc96 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -1,8 +1,8 @@
## -*- coding: utf-8 -*-
-<%inherit file="/master/index.mako" />
+<%inherit file="/master2/index.mako" />
-<%def name="head_tags()">
- ${parent.head_tags()}
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+%def>
+
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
% if label_profiles and request.has_perm('products.print_labels'):