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

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

10
docs/api/newgrids.rst Normal file
View file

@ -0,0 +1,10 @@
.. -*- coding: utf-8 -*-
``tailbone.newgrids``
=====================
.. automodule:: tailbone.newgrids
:members:
.. automodule:: tailbone.newgrids.alchemy
:members:

78
docs/api/views/master.rst Normal file
View file

@ -0,0 +1,78 @@
.. -*- coding: utf-8 -*-
``tailbone.views.master``
=========================
.. module:: tailbone.views.master
Model Master View
------------------
This module contains the "model master" view class. This is a convenience
abstraction which provides some patterns/consistency for the typical set of
views needed to expose a table's data for viewing/editing/etc. Usually this
means providing something like the following view methods for a model:
* index (list/filter)
* create
* view
* edit
* delete
The actual list of provided view methods will depend on usage. Generally
speaking, each view method which is provided by the master class may be
configured in some way by the subclass (e.g. add extra filters to a grid).
.. autoclass:: MasterView
.. automethod:: index
.. automethod:: create
.. automethod:: view
.. automethod:: edit
.. automethod:: delete
Attributes to Override
----------------------
The following is a list of attributes which you can (and in some cases must)
override when defining your subclass.
.. attribute:: MasterView.model_class
All master view subclasses *must* define this attribute. Its value must
be a data model class which has been mapped via SQLAlchemy, e.g.
``rattail.db.model.Product``.
.. attribute:: MasterView.normalized_model_name
Name of the model class which has been "normalized" for the sake of usage
as a key (for grid settings etc.). If not defined by the subclass, the
default will be the lower-cased model class name, e.g. 'product'.
.. attribute:: grid_key
Unique value to be used as a key for the grid settings, etc. If not
defined by the subclass, the normalized model name will be used.
.. attribute:: MasterView.route_prefix
Value with which all routes provided by the view class will be prefixed.
If not defined by the subclass, a default will be constructed by simply
adding an 's' to the end of the normalized model name, e.g. 'products'.
.. attribute:: MasterView.grid_factory
Factory callable to be used when creating new grid instances; defaults to
:class:`tailbone.newgrids.alchemy.AlchemyGrid`.
.. Methods to Override
.. -------------------
..
.. The following is a list of methods which you can override when defining your
.. subclass.
..
.. .. automethod:: MasterView.get_settings

View file

@ -34,9 +34,14 @@ execfile(os.path.join(os.pardir, 'tailbone', '_version.py'))
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
]
intersphinx_mapping = {
'formalchemy': ('http://docs.formalchemy.org/formalchemy/', None),
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -51,7 +56,7 @@ master_doc = 'index'
# General information about the project.
project = u'Tailbone'
copyright = u'2014, Lance Edgar'
copyright = u'2015, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@ -105,7 +110,7 @@ pygments_style = 'sphinx'
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the

View file

@ -22,8 +22,10 @@ Package API:
.. toctree::
:maxdepth: 1
api/newgrids
api/subscribers
api/views/batch
api/views/master
api/views/vendors.catalogs

View file

@ -25,7 +25,7 @@ Forms
"""
from .simpleform import *
from .alchemy import *
from .alchemy import AlchemyForm
from .fields import *
from .renderers import *

View file

@ -31,9 +31,6 @@ from pyramid.renderers import render
from ..db import Session
__all__ = ['AlchemyForm']
class AlchemyForm(Object):
"""
Form to contain a :class:`formalchemy.FieldSet` instance.

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Grids and Friends
"""
from . import filters
from .core import Grid, GridColumn, GridAction
from .alchemy import AlchemyGrid

View file

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
FormAlchemy Grid Classes
"""
from __future__ import unicode_literals
import logging
import sqlalchemy as sa
from sqlalchemy import orm
import formalchemy
from webhelpers import paginate
from tailbone.db import Session
from tailbone.newgrids import Grid, GridColumn, filters
log = logging.getLogger(__name__)
class AlchemyGrid(Grid):
"""
Grid class for use with SQLAlchemy data models.
Note that this is partially just a wrapper around the FormAlchemy grid, and
that you may use this in much the same way, e.g.::
grid = AlchemyGrid(...)
grid.configure(
include=[
field1,
field2,
])
grid.append(field3)
del grid.field1
grid.field2.set(renderer=SomeFieldRenderer)
"""
def __init__(self, *args, **kwargs):
super(AlchemyGrid, self).__init__(*args, **kwargs)
self._fa_grid = formalchemy.Grid(self.model_class, session=Session(),
request=self.request)
def __delattr__(self, attr):
delattr(self._fa_grid, attr)
def __getattr__(self, attr):
return getattr(self._fa_grid, attr)
def default_filters(self):
"""
SQLAlchemy grids are able to provide a default set of filters based on
the column properties mapped to the model class.
"""
filtrs = filters.GridFilterSet()
mapper = orm.class_mapper(self.model_class)
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty) and prop.key != 'uuid':
filtrs[prop.key] = self.make_filter(prop)
return filtrs
def make_filter(self, model_property, **kwargs):
"""
Make a filter suitable for use with the given model property.
"""
if len(model_property.columns) > 1:
log.debug("ignoring secondary columns for sake of type detection")
coltype = model_property.columns[0].type
factory = filters.AlchemyGridFilter
if isinstance(coltype, sa.String):
factory = filters.AlchemyStringFilter
elif isinstance(coltype, sa.Numeric):
factory = filters.AlchemyNumericFilter
kwargs['model_property'] = model_property
return factory(model_property.key, **kwargs)
def iter_filters(self):
"""
Iterate over the grid's complete set of filters.
"""
return self.filters.itervalues()
def make_sorters(self, sorters):
"""
Returns a mapping of sort options for the grid. Keyword args override
the defaults, which are obtained via the SQLAlchemy ORM.
"""
sorters, updates = {}, sorters
mapper = orm.class_mapper(self.model_class)
for key, column in mapper.columns.items():
if key != 'uuid':
sorters[key] = self.make_sorter(column)
if updates:
sorters.update(updates)
return sorters
def make_sorter(self, field):
"""
Returns a function suitable for a sort map callable, with typical logic
built in for sorting applied to ``field``.
"""
return lambda q, d: q.order_by(getattr(field, d)())
def load_settings(self):
"""
When a SQLAlchemy grid loads its settings, it must update the
underlying FormAlchemy grid instance with the final (filtered/etc.)
data set.
"""
super(AlchemyGrid, self).load_settings()
self._fa_grid.rebind(self.make_visible_data(), session=Session(),
request=self.request)
def sort_data(self, query):
"""
Sort the given query according to current settings, and return the result.
"""
# Cannot sort unless we know which column to sort by.
if not self.sortkey:
return query
# Cannot sort unless we have a sort function.
sortfunc = self.sorters.get(self.sortkey)
if not sortfunc:
return query
# We can provide a default sort direction though.
sortdir = getattr(self, 'sortdir', 'asc')
return sortfunc(query, sortdir)
def paginate_data(self, query):
"""
Paginate the given data set according to current settings, and return
the result.
"""
return paginate.Page(
query, item_count=query.count(),
items_per_page=self.pagesize, page=self.page,
url=paginate.PageURL_WebOb(self.request))
def iter_visible_columns(self):
"""
Returns an iterator for all currently-visible columns.
"""
for field in self._fa_grid.render_fields.itervalues():
column = GridColumn()
column.field = field
column.key = field.name
column.label = field.label()
yield column
def iter_rows(self):
for row in self._fa_grid.rows:
self._fa_grid._set_active(row, orm.object_session(row))
yield row
def render_cell(self, row, column):
return column.field.render_readonly()

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

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

View file

@ -0,0 +1,314 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Grid Filters
"""
from __future__ import unicode_literals
import sqlalchemy as sa
from edbob.util import prettify
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED
from pyramid_simpleform import Form
from pyramid_simpleform.renderers import FormRenderer
from webhelpers.html import HTML, tags
class FilterRenderer(object):
"""
Base class for all filter renderers.
"""
def render(self, value=None, **kwargs):
"""
Render the filter input element(s) as HTML. Default implementation
uses a simple text input.
"""
name = self.filter.key
return tags.text(name, value=value, id='filter.{0}.value'.format(name))
class DefaultRenderer(FilterRenderer):
"""
Default / fallback renderer.
"""
class NumericRenderer(FilterRenderer):
"""
Input renderer for numeric fields.
"""
class GridFilter(object):
"""
Represents a filter available to a grid. This is used to construct the
'filters' section when rendering the index page template.
"""
verbmap = {
'equal': "equal to",
'not_equal': "not equal to",
'greater_than': "greater than",
'greater_equal': "greater than or equal to",
'less_than': "less than",
'less_equal': "less than or equal to",
'is_null': "is null",
'is_not_null': "is not null",
'contains': "contains",
'does_not_contain': "does not contain",
}
def __init__(self, key, label=None, verbs=None, renderer=None,
default_active=False, default_verb=None, default_value=None):
self.key = key
self.label = label or prettify(key)
self.verbs = verbs or self.default_verbs()
self.renderer = renderer or DefaultRenderer()
self.renderer.filter = self
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
def __repr__(self):
return "GridFilter({0})".format(repr(self.key))
def default_verbs(self):
"""
Returns the set of verbs which will be used by default, i.e. unless
overridden by constructor args etc.
"""
return ['equal', 'not_equal', 'is_null', 'is_not_null']
def filter(self, data, verb=None, value=UNSPECIFIED):
"""
Filter the given data set according to a verb/value pair. If no verb
and/or value is specified by the caller, the filter will use its own
current verb/value by default.
"""
verb = verb or self.verb
value = value if value is not UNSPECIFIED else self.value
filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr:
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
return filtr(data, value)
def render(self, **kwargs):
kwargs['filter'] = self
return self.renderer.render(**kwargs)
class AlchemyGridFilter(GridFilter):
"""
Base class for SQLAlchemy grid filters.
"""
def __init__(self, *args, **kwargs):
self.model_property = kwargs.pop('model_property')
self.model_class = self.model_property.parent.class_
self.model_column = getattr(self.model_class, self.model_property.key)
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
Filter data with an equal ('=') query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column == value)
def filter_not_equal(self, query, value):
"""
Filter data with a not eqaul ('!=') query.
"""
if value is None or value == '':
return query
# When saying something is 'not equal' to something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.model_column == None,
self.model_column != value,
))
def filter_is_null(self, query, value):
"""
Filter data with an 'IS NULL' query. Note that this filter does not
use the value for anything.
"""
return query.filter(self.model_column == None)
def filter_is_not_null(self, query, value):
"""
Filter data with an 'IS NOT NULL' query. Note that this filter does
not use the value for anything.
"""
return query.filter(self.model_column != None)
def filter_greater_than(self, query, value):
"""
Filter data with a greater than ('>') query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column > value)
def filter_greater_equal(self, query, value):
"""
Filter data with a greater than or equal ('>=') query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column >= value)
def filter_less_than(self, query, value):
"""
Filter data with a less than ('<') query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column < value)
def filter_less_equal(self, query, value):
"""
Filter data with a less than or equal ('<=') query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column <= value)
class AlchemyStringFilter(AlchemyGridFilter):
"""
String filter for SQLAlchemy.
"""
def default_verbs(self):
"""
Expose contains / does-not-contain verbs in addition to core.
"""
return ['contains', 'does_not_contain',
'equal', 'not_equal', 'is_null', 'is_not_null']
def filter_contains(self, query, value):
"""
Filter data with a full 'ILIKE' query.
"""
if value is None or value == '':
return query
return query.filter(self.model_column.ilike('%{0}%'.format(value)))
def filter_does_not_contain(self, query, value):
"""
Filter data with a full 'NOT ILIKE' query.
"""
if value is None or value == '':
return query
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.model_column == None,
~self.model_column.ilike('%{0}%'.format(value)),
))
class AlchemyNumericFilter(AlchemyGridFilter):
"""
Numeric filter for SQLAlchemy.
"""
def default_verbs(self):
"""
Expose greater-than / less-than verbs in addition to core.
"""
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'is_null', 'is_not_null']
class GridFilterSet(OrderedDict):
"""
Collection class for :class:`GridFilter` instances.
"""
class GridFiltersForm(Form):
"""
Form for grid filters.
"""
def __init__(self, request, filters, *args, **kwargs):
super(GridFiltersForm, self).__init__(request, *args, **kwargs)
self.filters = filters
def iter_filters(self):
return self.filters.itervalues()
class GridFiltersFormRenderer(FormRenderer):
"""
Renderer for :class:`GridFiltersForm` instances.
"""
@property
def filters(self):
return self.form.filters
def iter_filters(self):
return self.form.iter_filters()
def tag(self, *args, **kwargs):
"""
Convenience method which passes all args to the
:func:`webhelpers:webhelpers.HTML.tag()` function.
"""
return HTML.tag(*args, **kwargs)
# TODO: This seems hacky..?
def checkbox(self, name, checked=None, **kwargs):
"""
Custom checkbox implementation.
"""
if name.endswith('-active'):
return tags.checkbox(name, checked=checked, **kwargs)
if checked is None:
checked = False
return super(GridFiltersFormRenderer, self).checkbox(name, checked=checked, **kwargs)
def filter_verb(self, filtr):
"""
Render the verb selection dropdown for the given filter.
"""
options = [(v, filtr.verbmap.get(v, "unknown verb '{0}'".format(v)))
for v in filtr.verbs]
return self.select('{0}.verb'.format(filtr.key), options, class_='verb')
def filter_value(self, filtr):
"""
Render the value input element(s) for the filter.
"""
# TODO: This surely needs some work..?
return HTML.tag('div', class_='value', c=filtr.render(value=self.value(filtr.key)))

View file

@ -64,6 +64,16 @@ body > #body-wrapper {
}
/******************************
* context menu
******************************/
#context-menu {
float: right;
list-style-type: none;
}
/******************************
* Panels
******************************/

View file

@ -0,0 +1,190 @@
/********************************************************************************
* newgrids.css
*
* Style tweaks for the new grids.
********************************************************************************/
/******************************
* filters
******************************/
.newgrid-wrapper .newfilters {
margin-right: 15em;
}
.newgrid-wrapper .newfilters fieldset {
margin: -8px 0 5px 0;
padding: 1px 5px 5px 5px;
}
.newgrid-wrapper .newfilters .filter {
margin-bottom: 2px;
}
.newgrid-wrapper .newfilters .filter:last-child {
margin-bottom: 0;
}
.newgrid-wrapper .newfilters .filter .toggle {
margin: 0;
text-align: left;
width: 15em;
}
.newgrid-wrapper .newfilters .ui-button-text {
line-height: 1.4em;
}
.newgrid-wrapper .newfilters .ui-button-text-icon-primary .ui-button-text {
padding: 0.2em 1em 0.2em 2.1em;
}
.newgrid-wrapper .newfilters .ui-selectmenu-button .ui-selectmenu-text {
padding: 0.2em 2.1em 0.2em 1em;
}
.newgrid-wrapper .newfilters .filter label {
font-weight: bold;
padding: 0.4em 0.2em;
position: relative;
top: -14px;
}
.newgrid-wrapper .newfilters .filter .value {
display: inline-block;
vertical-align: top;
}
.newgrid-wrapper .newfilters .filter .value input {
height: 19px;
}
.newgrid-wrapper .newfilters .filter .inputs {
display: inline-block;
/* TODO: Would be nice not to hard-code a height here... */
height: 26px;
vertical-align: top;
}
.newgrid-wrapper .newfilters .buttons {
margin: 0 0 5px 0;
}
.newgrid-wrapper .newfilters #add-filter-button {
margin-right: 5px;
vertical-align: middle;
}
/******************************
* table
******************************/
.newgrid table {
background-color: white;
border: 1px solid black;
border-collapse: collapse;
font-size: 10pt;
line-height: normal;
white-space: nowrap;
}
.newgrid.full table {
width: 100%;
}
/******************************
* thead
******************************/
.newgrid table thead th {
border-right: 1px solid black;
border-bottom: 1px solid black;
padding: 2px 3px;
}
.newgrid table thead th:last-child {
border-right: none;
}
.newgrid table thead th.sortable a {
display: block;
padding-right: 18px;
}
.newgrid table thead th.sorted {
background-position: right center;
background-repeat: no-repeat;
}
.newgrid table thead th.sorted.asc {
background-image: url(../img/sort_arrow_up.png);
}
.newgrid table thead th.sorted.desc {
background-image: url(../img/sort_arrow_down.png);
}
/******************************
* tbody
******************************/
.newgrid table tbody td {
padding: 5px 6px;
}
.newgrid table tbody tr:nth-child(odd) {
background-color: #e0e0e0;
}
.newgrid table tbody tr.hovering {
background-color: #bbbbbb;
}
/******************************
* main actions
******************************/
.newgrid .actions {
width: 1px;
}
.newgrid .actions a {
margin: 0 5px 0 0;
position: relative;
top: -2px;
}
.newgrid .actions a:last-child {
margin: 0;
}
.newgrid .actions .ui-icon {
display: inline-block;
position: relative;
top: 3px;
}
/******************************
* more actions
******************************/
.newgrid .actions div.more {
background-color: white;
border: 1px solid black;
display: none;
padding: 3px 10px 3px 5px;
position: absolute;
z-index: 1;
}
.newgrid .actions .more a {
display: block;
padding: 2px 0;
}

243
tailbone/static/js/jquery.ui.tailbone.js vendored Normal file
View file

@ -0,0 +1,243 @@
// -*- coding: utf-8 -*-
/**********************************************************************
* jQuery UI plugins for Tailbone
**********************************************************************/
/**********************************************************************
* gridwrapper plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridwrapper', {
_create: function() {
var that = this;
// Snag some element references.
this.filters = this.element.find('.newfilters');
this.add_filter = this.filters.find('#add-filter');
this.apply_filters = this.filters.find('#apply-filters');
this.grid = this.element.find('.newgrid');
// Enhance filters etc.
this.filters.find('.filter').gridfilter();
this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
if (! this.filters.find('.active:checked').length) {
this.apply_filters.button('disable');
}
this.add_filter.selectmenu({
width: '15em',
// Initially disabled if contains no enabled filter options.
disabled: this.add_filter.find('option:enabled').length == 1,
// When add-filter choice is made, show/focus new filter value input,
// and maybe hide the add-filter selection or show the apply button.
change: function (event, ui) {
var filter = that.filters.find('#filter-' + ui.item.value);
var select = $(this);
var option = ui.item.element;
filter.gridfilter('active', true);
filter.gridfilter('focus');
select.val('');
option.attr('disabled', 'disabled');
select.selectmenu('refresh');
if (select.find('option:enabled').length == 1) { // prompt is always enabled
select.selectmenu('disable');
} else {
that.apply_filters.button('enable');
}
}
});
// Intercept filters form submittal, and submit via AJAX instead.
this.filters.find('form').on('submit', function() {
var form = $(this);
var settings = {filter: true, partial: true};
form.find('.filter').each(function() {
// currently active filters will be included in form data
if ($(this).gridfilter('active')) {
settings[$(this).data('key')] = $(this).gridfilter('value');
settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
// others will be hidden from view
} else {
$(this).gridfilter('hide');
}
});
// if no filters are visible, disable submit button
if (! form.find('.filter:visible').length) {
that.apply_filters.button('disable');
}
// okay, submit filters to server and refresh grid
that.refresh(settings);
return false;
});
// 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;
});
// Refresh data when user chooses a new page size setting.
this.element.on('change', '.pager #pagesize', function() {
var settings = {
partial: true,
pagesize: $(this).val()
};
that.refresh(settings);
});
// Refresh data when user clicks a pager link.
this.element.on('click', '.pager a', function() {
that.refresh(this.search.substring(1)); // remove leading '?'
return false;
});
// Add hover highlight effect to grid rows during mouse-over.
this.element.on('mouseenter', 'tbody tr', function() {
$(this).addClass('hovering');
});
this.element.on('mouseleave', 'tbody tr', function() {
$(this).removeClass('hovering');
});
// Show 'more' actions when user hovers over 'more' link.
this.element.on('mouseenter', '.actions a.more', function() {
that.grid.find('.actions div.more').hide();
$(this).siblings('div.more')
.show()
.position({my: 'left-5 top-4', at: 'left top', of: $(this)});
});
this.element.on('mouseleave', '.actions div.more', function() {
$(this).hide();
});
},
// Refreshes the visible data within the grid, according to the given settings.
refresh: function(settings) {
var that = this;
this.element.mask("Refreshing data...");
$.get(this.grid.data('url'), settings, function(data) {
that.grid.replaceWith(data);
that.grid = that.element.find('.newgrid');
that.element.unmask();
});
}
});
})( jQuery );
/**********************************************************************
* gridfilter plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridfilter', {
_create: function() {
// Track down some important elements.
this.checkbox = this.element.find('input[name$="-active"]');
this.label = this.element.find('label');
this.inputs = this.element.find('.inputs');
this.add_filter = this.element.parents('.newgrid-wrapper').find('#add-filter');
// Hide the checkbox and label, and add button for toggling active status.
this.checkbox.addClass('ui-helper-hidden-accessible');
this.label.hide();
this.activebutton = $('<button type="button" class="toggle" />')
.insertAfter(this.label)
.text(this.label.text())
.button({
icons: {primary: 'ui-icon-blank'}
});
// Enhance some more stuff.
this.inputs.find('.verb').selectmenu({width: '15em'});
// Listen for button click, to keep checkbox in sync.
this._on(this.activebutton, {
click: function(e) {
var checked = !this.checkbox.is(':checked');
this.checkbox.prop('checked', checked);
this.refresh();
if (checked) {
this.focus();
}
}
});
// Update the initial state of the button according to checkbox.
this.refresh();
},
refresh: function() {
if (this.checkbox.is(':checked')) {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
this.inputs.show();
} else {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
this.inputs.hide();
}
},
active: function(value) {
if (value === undefined) {
return this.checkbox.is(':checked');
}
if (value) {
if (!this.checkbox.is(':checked')) {
this.checkbox.prop('checked', true);
this.refresh();
this.element.show();
}
} else if (this.checkbox.is(':checked')) {
this.checkbox.prop('checked', false);
this.refresh();
}
},
hide: function() {
this.active(false);
this.element.hide();
var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
option.attr('disabled', false);
if (this.add_filter.selectmenu('option', 'disabled')) {
this.add_filter.selectmenu('enable');
}
this.add_filter.selectmenu('refresh');
},
focus: function() {
this.inputs.find('.value input').focus();
},
value: function() {
return this.inputs.find('.value input').val();
},
verb: function() {
return this.inputs.find('.verb').val();
}
});
})( jQuery );

View file

@ -112,6 +112,11 @@ $(function() {
$('input[type=submit]').button();
$('input[type=reset]').button();
/*
* Enhance new-style grids.
*/
$('.newgrid-wrapper').gridwrapper();
/*
* When filter labels are clicked, (un)check the associated checkbox.
*/

View file

@ -131,6 +131,7 @@
${h.javascript_link('https://code.jquery.com/ui/1.11.4/jquery-ui.min.js')}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js'))}
</%def>
@ -145,6 +146,7 @@
${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'))}
${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'))}
</%def>
<%def name="head_tags()"></%def>

View file

@ -0,0 +1,16 @@
## -*- coding: utf-8 -*-
<%inherit file="/base.mako" />
<%def name="title()">New ${model_title}</%def>
<%def name="context_menu_items()">
<li>${h.link_to("Back to {0}".format(model_title_plural), index_url)}</li>
</%def>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
<div class="form-wrapper">
${form.render()|n}
</div><!-- form-wrapper -->

View file

@ -0,0 +1,19 @@
## -*- coding: utf-8 -*-
<%inherit file="/base.mako" />
<%def name="title()">${model_title}: ${unicode(instance)}</%def>
<%def name="context_menu_items()">
<li>${h.link_to("Back to {0}".format(model_title_plural), url(route_prefix))}</li>
% if request.has_perm('{0}.view'.format(permission_prefix)):
<li>${h.link_to("View this {0}".format(model_title), action_url('view', instance))}</li>
% endif
</%def>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
<div class="form-wrapper">
${form.render()|n}
</div><!-- form-wrapper -->

View file

@ -0,0 +1,22 @@
## -*- coding: utf-8 -*-
## ##############################################################################
##
## Default master 'index' template. Features a prominent data table and
## exposes a way to filter and sort the data, etc.
##
## ##############################################################################
<%inherit file="/base.mako" />
<%def name="title()">${grid.model_title_plural}</%def>
<%def name="context_menu_items()">
% if request.has_perm('{0}.create'.format(grid.permission_prefix)):
<li>${h.link_to("Create a new {0}".format(grid.model_title), url('{0}.create'.format(grid.route_prefix)))}</li>
% endif
</%def>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
${grid.render_complete()|n}

View file

@ -0,0 +1,19 @@
## -*- coding: utf-8 -*-
<%inherit file="/base.mako" />
<%def name="title()">${model_title}: ${unicode(instance)}</%def>
<%def name="context_menu_items()">
<li>${h.link_to("Back to {0}".format(model_title_plural), url(route_prefix))}</li>
% if request.has_perm('{0}.edit'.format(permission_prefix)):
<li>${h.link_to("Edit this {0}".format(model_title), action_url('edit', instance))}</li>
% endif
</%def>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
<div class="form-wrapper">
${form.render()|n}
</div><!-- form-wrapper -->

View file

@ -0,0 +1,7 @@
## -*- coding: utf-8 -*-
<div class="newgrid-wrapper">
% if grid.filterable:
${grid.render_filters()|n}
% endif
${grid.render_grid()|n}
</div><!-- newgrid-wrapper -->

View file

@ -0,0 +1,31 @@
## -*- coding: utf-8 -*-
<div class="newfilters">
${form.begin(method='get')}
<fieldset>
<legend>Filters</legend>
% for filtr in form.iter_filters():
<div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}>
${form.checkbox('{0}-active'.format(filtr.key), class_='active', id='filter-active-{0}'.format(filtr.key), checked=filtr.active)}
<label for="filter-active-${filtr.key}">${filtr.label}</label>
<div class="inputs">
${form.filter_verb(filtr)}
${form.filter_value(filtr)}
</div>
</div>
% endfor
</fieldset>
<div class="buttons">
${form.tag('button', type='submit', id='apply-filters', c="Apply Filters")}
<select id="add-filter">
<option value="">Add a Filter</option>
% for filtr in form.iter_filters():
<option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option>
% endfor
</select>
</div>
${form.end()}
</div><!-- newfilters -->

View file

@ -0,0 +1,50 @@
## -*- coding: utf-8 -*-
<div ${format_attrs(**grid.get_div_attrs())}>
<table>
<thead>
<tr>
## % if grid.checkboxes:
## <th class="checkbox">${h.checkbox('check-all')}</th>
## % endif
% for column in grid.iter_visible_columns():
${grid.column_header(column)}
% endfor
% if grid.show_actions_column:
<th class="actions">Actions</th>
% endif
</tr>
</thead>
<tbody>
% for i, row in enumerate(grid.iter_rows(), 1):
<tr ${format_attrs(**grid.get_row_attrs(row, i))}>
## % if grid.checkboxes:
## <td class="checkbox">${grid.checkbox(row)}</td>
## % endif
% for column in grid.iter_visible_columns():
<td ${format_attrs(**grid.get_cell_attrs(row, column))}>${grid.render_cell(row, column)}</td>
% endfor
% if grid.show_actions_column:
<td class="actions">
${grid.render_actions(row)}
</td>
% endif
</tr>
% endfor
</tbody>
</table>
% if grid.pageable:
<div class="pager">
<p class="showing">
showing ${grid.pager.first_item} thru ${grid.pager.last_item} of ${grid.pager.item_count}
% if grid.pager.page_count > 1:
(page ${grid.pager.page} of ${grid.pager.page_count})
% endif
</p>
<p class="page-links">
${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())}
per page&nbsp;
${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev', partial=1)}
</p>
</div>
% endif
</div>

View file

@ -1,12 +0,0 @@
## -*- coding: utf-8 -*-
<%inherit file="/grid.mako" />
<%def name="title()">Settings</%def>
<%def name="context_menu_items()">
% if request.has_perm('settings.create'):
<li>${h.link_to("Create a new Setting", url('settings.create'))}</li>
% endif
</%def>
${parent.body()}

View file

@ -30,6 +30,7 @@ from tailbone.views.grids import (
GridView, AlchemyGridView, SortableAlchemyGridView,
PagedAlchemyGridView, SearchableAlchemyGridView)
from .crud import *
from .master import MasterView
from tailbone.views.autocomplete import AutocompleteView

449
tailbone/views/master.py Normal file
View file

@ -0,0 +1,449 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model Master View
"""
from __future__ import unicode_literals
import sqlalchemy as sa
from sqlalchemy import orm
from edbob.util import prettify
import formalchemy
from pyramid.renderers import get_renderer, render_to_response
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from tailbone.db import Session
from tailbone.views import View
from tailbone.newgrids import filters, AlchemyGrid, GridAction
from tailbone.forms import AlchemyForm
class MasterView(View):
"""
Base "master" view class. All model master views should derive from this.
"""
##############################
# Available Views
##############################
def index(self):
"""
View to list/filter/sort the model data.
If this view receives a non-empty 'partial' parameter in the query
string, then the view will return the renderered grid only. Otherwise
returns the full page.
"""
grid = self.make_grid()
if self.request.params.get('partial'):
self.request.response.content_type = b'text/html'
self.request.response.text = grid.render_grid()
return self.request.response
return self.render_to_response('index', {'grid': grid})
def create(self):
"""
View for creating a new model record.
"""
form = self.make_form(self.model_class, creating=True)
if self.request.method == 'POST':
if form.validate():
form.save()
instance = form.fieldset.model
self.request.session.flash("{0} {1} has been created.".format(
self.get_model_title(), instance))
return HTTPFound(location=self.get_action_url('view', instance))
return self.render_to_response('create', {'form': form})
def view(self):
"""
View for viewing details of an existing model record.
"""
key = self.request.matchdict[self.get_model_key()]
instance = Session.query(self.model_class).get(key)
if not instance:
return HTTPNotFound()
form = self.make_form(instance, readonly=True)
return self.render_to_response('view', {
'instance': instance, 'form': form})
def edit(self):
"""
View for editing an existing model record.
"""
key = self.request.matchdict[self.get_model_key()]
instance = Session.query(self.model_class).get(key)
if not instance:
return HTTPNotFound()
form = self.make_form(instance, editing=True)
if self.request.method == 'POST':
if form.validate():
form.save()
self.request.session.flash("{0} {1} has been updated.".format(
self.get_model_title(), instance))
return HTTPFound(location=self.get_action_url('view', instance))
return self.render_to_response('edit', {
'instance': instance, 'form': form})
def delete(self):
"""
View for deleting an existing model record.
"""
key = self.request.matchdict[self.get_model_key()]
instance = Session.query(self.model_class).get(key)
if not instance:
return HTTPNotFound()
# Flush immediately to force any pending integrity errors etc.; that
# way we don't set flash message until we know we have success.
Session.delete(instance)
Session.flush()
self.request.session.flash("{0} {1} has been deleted.".format(
self.get_model_title(), instance))
return HTTPFound(location=self.get_index_url())
##############################
# Core Stuff
##############################
@classmethod
def get_model_class(cls):
"""
Returns the data model class for which the master view exists.
"""
if not hasattr(cls, 'model_class'):
raise NotImplementedError("You must define the `model_class` for: {0}".format(cls))
return cls.model_class
@classmethod
def get_normalized_model_name(cls):
"""
Returns the "normalized" name for the view's model class. This will be
the value of the :attr:`normalized_model_name` attribute if defined;
otherwise it will be a simple lower-cased version of the associated
model class name.
"""
return getattr(cls, 'normalized_model_name', cls.get_model_class().__name__.lower())
@classmethod
def get_model_key(cls):
"""
Return a string name for the primary key of the model class.
"""
if hasattr(cls, 'model_key'):
return cls.model_key
mapper = orm.class_mapper(cls.get_model_class())
return ','.join([k.key for k in mapper.primary_key])
@classmethod
def get_model_title(cls):
"""
Return a "humanized" version of the model name, for display in templates.
"""
return getattr(cls, 'model_title', cls.model_class.__name__)
@classmethod
def get_model_title_plural(cls):
"""
Return a "humanized" (and plural) version of the model name, for
display in templates.
"""
return getattr(cls, 'model_title_plural', '{0}s'.format(cls.get_model_title()))
@classmethod
def get_route_prefix(cls):
"""
Returns a prefix which (by default) applies to all routes provided by
the master view class. This is the plural, lower-cased name of the
model class by default, e.g. 'products'.
"""
model_name = cls.get_normalized_model_name()
return getattr(cls, 'route_prefix', '{0}s'.format(model_name))
@classmethod
def get_url_prefix(cls):
"""
Returns a prefix which (by default) applies to all URLs provided by the
master view class. By default this is the route prefix, preceded by a
slash, e.g. '/products'.
"""
return getattr(cls, 'url_prefix', '/{0}'.format(cls.get_route_prefix()))
@classmethod
def get_template_prefix(cls):
"""
Returns a prefix which (by default) applies to all templates required by
the master view class. This uses the URL prefix by default.
"""
return getattr(cls, 'template_prefix', cls.get_url_prefix())
@classmethod
def get_permission_prefix(cls):
"""
Returns a prefix which (by default) applies to all permissions leveraged by
the master view class. This uses the route prefix by default.
"""
return getattr(cls, 'permission_prefix', cls.get_route_prefix())
def get_index_url(self):
"""
Returns the master view's index URL.
"""
return self.request.route_url(self.get_route_prefix())
def get_action_url(self, action, instance):
"""
Generate a URL for the given action on the given instance.
"""
return self.request.route_url('{0}.{1}'.format(self.get_route_prefix(), action),
**self.get_action_route_kwargs(instance))
def render_to_response(self, template, data):
"""
Return a response with the given template rendered with the given data.
Note that ``template`` must only be a "key" (e.g. 'index' or 'view').
First an attempt will be made to render using the :attr:`template_prefix`.
If that doesn't work, another attempt will be made using '/master' as
the template prefix.
"""
data.update({
'model_title': self.get_model_title(),
'model_title_plural': self.get_model_title_plural(),
'route_prefix': self.get_route_prefix(),
'permission_prefix': self.get_permission_prefix(),
'index_url': self.get_index_url(),
'action_url': self.get_action_url,
})
try:
return render_to_response('{0}/{1}.mako'.format(self.get_template_prefix(), template),
data, request=self.request)
except IOError:
return render_to_response('/master/{0}.mako'.format(template),
data, request=self.request)
##############################
# Grid Stuff
##############################
@classmethod
def get_grid_factory(cls):
"""
Returns the grid factory or class which is to be used when creating new
grid instances.
"""
return getattr(cls, 'grid_factory', AlchemyGrid)
@classmethod
def get_grid_key(cls):
"""
Returns the unique key to be used for the grid, for caching sort/filter
options etc.
"""
return getattr(cls, 'grid_key', '{0}s'.format(cls.get_normalized_model_name()))
def make_grid_kwargs(self):
"""
Return a dictionary of kwargs to be passed to the factory when creating
new grid instances.
"""
return {
'width': 'full',
'filterable': True,
'sortable': True,
'default_sortkey': getattr(self, 'default_sortkey', None),
'sortdir': getattr(self, 'sortdir', 'asc'),
'pageable': True,
'main_actions': self.get_main_actions(),
'more_actions': self.get_more_actions(),
'model_title': self.get_model_title(),
'model_title_plural': self.get_model_title_plural(),
'permission_prefix': self.get_permission_prefix(),
'route_prefix': self.get_route_prefix(),
}
def get_main_actions(self):
"""
Return a list of 'main' actions for the grid.
"""
return [
self.make_action('view', icon='zoomin'),
]
def get_more_actions(self):
"""
Return a list of 'more' actions for the grid.
"""
return [
self.make_action('edit', icon='pencil'),
self.make_action('delete', icon='trash'),
]
def make_action(self, key, **kwargs):
"""
Make a new :class:`GridAction` instance for the current grid.
"""
kwargs.setdefault('url', lambda r: self.request.route_url(
'{0}.{1}'.format(self.get_route_prefix(), key),
**self.get_action_route_kwargs(r)))
return GridAction(key, **kwargs)
def get_action_route_kwargs(self, row):
"""
Hopefully generic kwarg generator for basic action routes.
"""
mapper = orm.object_mapper(row)
keys = [k.key for k in mapper.primary_key]
values = [getattr(row, k) for k in keys]
return dict(zip(keys, values))
def make_grid(self):
"""
Make and return a new (configured) grid instance.
"""
factory = self.get_grid_factory()
key = self.get_grid_key()
data = self.make_query()
kwargs = self.make_grid_kwargs()
grid = factory(key, self.request, data=data, model_class=self.model_class, **kwargs)
self.configure_grid(grid)
grid.load_settings()
return grid
def configure_grid(self, grid):
"""
Configure the grid, customizing as necessary. Subclasses are
encouraged to override this method.
As a bare minimum, the logic for this method must at some point invoke
the ``configure()`` method on the grid instance. The default
implementation does exactly (and only) this, passing no arguments.
This requirement is a result of using FormAlchemy under the hood, and
it is in fact a call to :meth:`formalchemy:formalchemy.tables.Grid.configure()`.
"""
grid.configure()
def make_query(self, session=None):
"""
Make the base query to be used for the grid. Note that this query will
have been prefiltered but otherwise will be "pure". The user's filter
selections etc. are later applied to this query.
"""
if session is None:
session = Session()
query = session.query(self.model_class)
return self.prefilter_query(query)
def prefilter_query(self, query):
"""
Apply any sort of pre-filtering to the grid query, as necessary. This
is useful if say, you don't ever want to show records of a certain type
to non-admin users. You would use a "prefilter" to hide what you
wanted, regardless of the user's filter selections.
"""
return query
##############################
# CRUD Stuff
##############################
def make_form(self, instance, **kwargs):
"""
Make a FormAlchemy-based form for use with CRUD views.
"""
# TODO: Some hacky stuff here, to accommodate old form cruft. Probably
# should refactor forms soon too, but trying to avoid it for the moment.
readonly = kwargs.pop('readonly', False)
kwargs.setdefault('creating', False)
kwargs.setdefault('editing', False)
# Ugh, these attributes must be present on the view..?
self.creating = kwargs['creating']
self.editing = kwargs['editing']
fieldset = self.make_fieldset(instance)
self.configure_fieldset(fieldset)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if self.creating:
kwargs.setdefault('cancel_url', self.get_index_url())
else:
kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
form = AlchemyForm(self.request, fieldset, **kwargs)
form.readonly = readonly
return form
def make_fieldset(self, instance, **kwargs):
"""
Make a FormAlchemy fieldset for the given model instance.
"""
kwargs.setdefault('session', Session())
kwargs.setdefault('request', self.request)
fieldset = formalchemy.FieldSet(instance, **kwargs)
fieldset.prettify = prettify
return fieldset
##############################
# Config Stuff
##############################
@classmethod
def defaults(cls, config):
"""
Provide default configuration for a master view.
"""
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
model_key = cls.get_model_key()
# list/search
config.add_route(route_prefix, '{0}/'.format(url_prefix))
config.add_view(cls, attr='index', route_name=route_prefix,
permission='{0}.list'.format(permission_prefix))
# create
config.add_route('{0}.create'.format(route_prefix), '{0}/new'.format(url_prefix))
config.add_view(cls, attr='create', route_name='{0}.create'.format(route_prefix),
permission='{0}.create'.format(permission_prefix))
# view
config.add_route('{0}.view'.format(route_prefix), '{0}/{{{1}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='view', route_name='{0}.view'.format(route_prefix),
permission='{0}.view'.format(permission_prefix))
# edit
config.add_route('{0}.edit'.format(route_prefix), '{0}/{{{1}}}/edit'.format(url_prefix, model_key))
config.add_view(cls, attr='edit', route_name='{0}.edit'.format(route_prefix),
permission='{0}.edit'.format(permission_prefix))
# delete
config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key))
config.add_view(cls, attr='delete', route_name='{0}.delete'.format(route_prefix),
permission='{0}.delete'.format(permission_prefix))

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2014 Lance Edgar
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
@ -28,95 +28,35 @@ from __future__ import unicode_literals
from rattail.db import model
from tailbone.views import SearchableAlchemyGridView, CrudView
from tailbone.views import MasterView
class SettingsGrid(SearchableAlchemyGridView):
class SettingsView(MasterView):
"""
Master view for the settings model.
"""
model_class = model.Setting
mapped_class = model.Setting
config_prefix = 'settings'
sort = 'name'
def filter_map(self):
return self.make_filter_map(ilike=['name', 'value'])
def filter_config(self):
return self.make_filter_config(
include_filter_name=True,
filter_type_name='lk',
include_filter_value=True,
filter_type_value='lk')
def sort_map(self):
return self.make_sort_map('name', 'value')
def grid(self):
g = self.make_grid()
def configure_grid(self, g):
g.filters['name'].default_active = True
g.filters['name'].default_verb = 'contains'
g.default_sortkey = 'name'
g.configure(
include=[
g.name,
g.value,
],
],
readonly=True)
if self.request.has_perm('settings.view'):
g.viewable = True
g.view_route_name = 'settings.view'
if self.request.has_perm('settings.edit'):
g.editable = True
g.edit_route_name = 'settings.edit'
if self.request.has_perm('settings.delete'):
g.deletable = True
g.delete_route_name = 'settings.delete'
return g
class SettingCrud(CrudView):
mapped_class = model.Setting
home_route = 'settings'
def fieldset(self, model):
fs = self.make_fieldset(model)
def configure_fieldset(self, fs):
fs.configure(
include=[
fs.name,
fs.value,
])
if self.updating:
])
if self.editing:
fs.name.set(readonly=True)
return fs
def add_routes(config):
config.add_route('settings', '/settings')
config.add_route('settings.create', '/settings/new')
config.add_route('settings.view', '/settings/{name}')
config.add_route('settings.edit', '/settings/{name}/edit')
config.add_route('settings.delete', '/settings/{name}/delete')
def includeme(config):
add_routes(config)
# Grid
config.add_view(SettingsGrid,
route_name='settings',
renderer='/settings/index.mako',
permission='settings.list')
# CRUD
config.add_view(SettingCrud, attr='create',
route_name='settings.create',
renderer='/settings/crud.mako',
permission='settings.create')
config.add_view(SettingCrud, attr='read',
route_name='settings.view',
renderer='/settings/crud.mako',
permission='settings.view')
config.add_view(SettingCrud, attr='update',
route_name='settings.edit',
renderer='/settings/crud.mako',
permission='settings.edit')
config.add_view(SettingCrud, attr='delete',
route_name='settings.delete',
permission='settings.delete')
SettingsView.defaults(config)