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 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 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 name="extra_styles()"> + ${parent.extra_styles()} - <%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 name="extra_javascript()"> + ${parent.extra_javascript()} % if label_profiles and request.has_perm('products.print_labels'):