Add support for "new-style grids" and "model master views".
Finally, an API that makes some sense... We don't yet have feature parity with the old-style grids and CRUD views, but this is already a significant improvement to the design. Still needs a lot of docs though...
This commit is contained in:
parent
62b7194c21
commit
585eb09bec
10
docs/api/newgrids.rst
Normal file
10
docs/api/newgrids.rst
Normal 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
78
docs/api/views/master.rst
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -22,8 +22,10 @@ Package API:
|
|||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
api/newgrids
|
||||
api/subscribers
|
||||
api/views/batch
|
||||
api/views/master
|
||||
api/views/vendors.catalogs
|
||||
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ Forms
|
|||
"""
|
||||
|
||||
from .simpleform import *
|
||||
from .alchemy import *
|
||||
from .alchemy import AlchemyForm
|
||||
from .fields import *
|
||||
from .renderers import *
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
29
tailbone/newgrids/__init__.py
Normal file
29
tailbone/newgrids/__init__.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2015 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Grids and Friends
|
||||
"""
|
||||
|
||||
from . import filters
|
||||
from .core import Grid, GridColumn, GridAction
|
||||
from .alchemy import AlchemyGrid
|
183
tailbone/newgrids/alchemy.py
Normal file
183
tailbone/newgrids/alchemy.py
Normal file
|
@ -0,0 +1,183 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2015 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
FormAlchemy Grid Classes
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
import formalchemy
|
||||
from webhelpers import paginate
|
||||
|
||||
from tailbone.db import Session
|
||||
from tailbone.newgrids import Grid, GridColumn, filters
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlchemyGrid(Grid):
|
||||
"""
|
||||
Grid class for use with SQLAlchemy data models.
|
||||
|
||||
Note that this is partially just a wrapper around the FormAlchemy grid, and
|
||||
that you may use this in much the same way, e.g.::
|
||||
|
||||
grid = AlchemyGrid(...)
|
||||
grid.configure(
|
||||
include=[
|
||||
field1,
|
||||
field2,
|
||||
])
|
||||
grid.append(field3)
|
||||
del grid.field1
|
||||
grid.field2.set(renderer=SomeFieldRenderer)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AlchemyGrid, self).__init__(*args, **kwargs)
|
||||
self._fa_grid = formalchemy.Grid(self.model_class, session=Session(),
|
||||
request=self.request)
|
||||
|
||||
def __delattr__(self, attr):
|
||||
delattr(self._fa_grid, attr)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self._fa_grid, attr)
|
||||
|
||||
def default_filters(self):
|
||||
"""
|
||||
SQLAlchemy grids are able to provide a default set of filters based on
|
||||
the column properties mapped to the model class.
|
||||
"""
|
||||
filtrs = filters.GridFilterSet()
|
||||
mapper = orm.class_mapper(self.model_class)
|
||||
for prop in mapper.iterate_properties:
|
||||
if isinstance(prop, orm.ColumnProperty) and prop.key != 'uuid':
|
||||
filtrs[prop.key] = self.make_filter(prop)
|
||||
return filtrs
|
||||
|
||||
def make_filter(self, model_property, **kwargs):
|
||||
"""
|
||||
Make a filter suitable for use with the given model property.
|
||||
"""
|
||||
if len(model_property.columns) > 1:
|
||||
log.debug("ignoring secondary columns for sake of type detection")
|
||||
coltype = model_property.columns[0].type
|
||||
|
||||
factory = filters.AlchemyGridFilter
|
||||
if isinstance(coltype, sa.String):
|
||||
factory = filters.AlchemyStringFilter
|
||||
elif isinstance(coltype, sa.Numeric):
|
||||
factory = filters.AlchemyNumericFilter
|
||||
|
||||
kwargs['model_property'] = model_property
|
||||
return factory(model_property.key, **kwargs)
|
||||
|
||||
def iter_filters(self):
|
||||
"""
|
||||
Iterate over the grid's complete set of filters.
|
||||
"""
|
||||
return self.filters.itervalues()
|
||||
|
||||
def make_sorters(self, sorters):
|
||||
"""
|
||||
Returns a mapping of sort options for the grid. Keyword args override
|
||||
the defaults, which are obtained via the SQLAlchemy ORM.
|
||||
"""
|
||||
sorters, updates = {}, sorters
|
||||
mapper = orm.class_mapper(self.model_class)
|
||||
for key, column in mapper.columns.items():
|
||||
if key != 'uuid':
|
||||
sorters[key] = self.make_sorter(column)
|
||||
if updates:
|
||||
sorters.update(updates)
|
||||
return sorters
|
||||
|
||||
def make_sorter(self, field):
|
||||
"""
|
||||
Returns a function suitable for a sort map callable, with typical logic
|
||||
built in for sorting applied to ``field``.
|
||||
"""
|
||||
return lambda q, d: q.order_by(getattr(field, d)())
|
||||
|
||||
def load_settings(self):
|
||||
"""
|
||||
When a SQLAlchemy grid loads its settings, it must update the
|
||||
underlying FormAlchemy grid instance with the final (filtered/etc.)
|
||||
data set.
|
||||
"""
|
||||
super(AlchemyGrid, self).load_settings()
|
||||
self._fa_grid.rebind(self.make_visible_data(), session=Session(),
|
||||
request=self.request)
|
||||
|
||||
def sort_data(self, query):
|
||||
"""
|
||||
Sort the given query according to current settings, and return the result.
|
||||
"""
|
||||
# Cannot sort unless we know which column to sort by.
|
||||
if not self.sortkey:
|
||||
return query
|
||||
|
||||
# Cannot sort unless we have a sort function.
|
||||
sortfunc = self.sorters.get(self.sortkey)
|
||||
if not sortfunc:
|
||||
return query
|
||||
|
||||
# We can provide a default sort direction though.
|
||||
sortdir = getattr(self, 'sortdir', 'asc')
|
||||
return sortfunc(query, sortdir)
|
||||
|
||||
def paginate_data(self, query):
|
||||
"""
|
||||
Paginate the given data set according to current settings, and return
|
||||
the result.
|
||||
"""
|
||||
return paginate.Page(
|
||||
query, item_count=query.count(),
|
||||
items_per_page=self.pagesize, page=self.page,
|
||||
url=paginate.PageURL_WebOb(self.request))
|
||||
|
||||
def iter_visible_columns(self):
|
||||
"""
|
||||
Returns an iterator for all currently-visible columns.
|
||||
"""
|
||||
for field in self._fa_grid.render_fields.itervalues():
|
||||
column = GridColumn()
|
||||
column.field = field
|
||||
column.key = field.name
|
||||
column.label = field.label()
|
||||
yield column
|
||||
|
||||
def iter_rows(self):
|
||||
for row in self._fa_grid.rows:
|
||||
self._fa_grid._set_active(row, orm.object_session(row))
|
||||
yield row
|
||||
|
||||
def render_cell(self, row, column):
|
||||
return column.field.render_readonly()
|
592
tailbone/newgrids/core.py
Normal file
592
tailbone/newgrids/core.py
Normal file
|
@ -0,0 +1,592 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2015 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Core Grid Classes
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from pyramid.renderers import render
|
||||
from webhelpers.html import HTML, tags
|
||||
from webhelpers.html.builder import format_attrs
|
||||
|
||||
from tailbone.newgrids import filters
|
||||
|
||||
|
||||
class Grid(object):
|
||||
"""
|
||||
Core grid class. In sore need of documentation.
|
||||
"""
|
||||
|
||||
def __init__(self, key, request, columns=[], data=[], main_actions=[], more_actions=[],
|
||||
filterable=False, filters={},
|
||||
sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
|
||||
pageable=False, default_pagesize=20, default_page=1,
|
||||
width='auto', checkboxes=False, **kwargs):
|
||||
self.key = key
|
||||
self.request = request
|
||||
self.columns = columns
|
||||
self.data = data
|
||||
self.main_actions = main_actions
|
||||
self.more_actions = more_actions
|
||||
|
||||
# Set extra attributes first, in case other init logic depends on any
|
||||
# of them (i.e. in subclasses).
|
||||
for kw, val in kwargs.iteritems():
|
||||
setattr(self, kw, val)
|
||||
|
||||
self.filterable = filterable
|
||||
if self.filterable:
|
||||
self.filters = self.make_filters(filters)
|
||||
|
||||
self.sortable = sortable
|
||||
if self.sortable:
|
||||
self.sorters = self.make_sorters(sorters)
|
||||
self.default_sortkey = default_sortkey
|
||||
self.default_sortdir = default_sortdir
|
||||
|
||||
self.pageable = pageable
|
||||
if self.pageable:
|
||||
self.default_pagesize = default_pagesize
|
||||
self.default_page = default_page
|
||||
|
||||
self.width = width
|
||||
self.checkboxes = checkboxes
|
||||
|
||||
def get_default_filters(self):
|
||||
"""
|
||||
Returns the default set of filters provided by the grid.
|
||||
"""
|
||||
if hasattr(self, 'default_filters'):
|
||||
if callable(self.default_filters):
|
||||
return self.default_filters()
|
||||
return self.default_filters
|
||||
return filters.GridFilterSet()
|
||||
|
||||
def make_filters(self, filters=None):
|
||||
"""
|
||||
Returns an initial set of filters which will be available to the grid.
|
||||
The grid itself may or may not provide some default filters, and the
|
||||
``filters`` kwarg may contain additions and/or overrides.
|
||||
"""
|
||||
filters, updates = self.get_default_filters(), filters
|
||||
if updates:
|
||||
filters.update(updates)
|
||||
return filters
|
||||
|
||||
def iter_filters(self):
|
||||
"""
|
||||
Iterate over all filters available to the grid.
|
||||
"""
|
||||
return self.filters.itervalues()
|
||||
|
||||
def iter_active_filters(self):
|
||||
"""
|
||||
Iterate over all *active* filters for the grid. Whether a filter is
|
||||
active is determined by current grid settings.
|
||||
"""
|
||||
for filtr in self.iter_filters():
|
||||
if filtr.active:
|
||||
yield filtr
|
||||
|
||||
def has_active_filters(self):
|
||||
"""
|
||||
Returns boolean indicating whether the grid contains any *active*
|
||||
filters, according to current settings.
|
||||
"""
|
||||
for filtr in self.iter_active_filters():
|
||||
return True
|
||||
return False
|
||||
|
||||
def make_sorters(self, sorters=None):
|
||||
"""
|
||||
Returns an initial set of sorters which will be available to the grid.
|
||||
The grid itself may or may not provide some default sorters, and the
|
||||
``sorters`` kwarg may contain additions and/or overrides.
|
||||
"""
|
||||
sorters, updates = {}, sorters
|
||||
if updates:
|
||||
sorters.update(updates)
|
||||
return sorters
|
||||
|
||||
def load_settings(self, store=True):
|
||||
"""
|
||||
Load current/effective settings for the grid, from the request query
|
||||
string and/or session storage. If ``store`` is true, then once
|
||||
settings have been fully read, they are stored in current session for
|
||||
next time. Finally, various instance attributes of the grid and its
|
||||
filters are updated in-place to reflect the settings; this is so code
|
||||
needn't access the settings dict directly, but the more Pythonic
|
||||
instance attributes.
|
||||
"""
|
||||
# Initial settings come from class defaults.
|
||||
settings = {
|
||||
'sortkey': self.default_sortkey,
|
||||
'sortdir': self.default_sortdir,
|
||||
'pagesize': self.default_pagesize,
|
||||
'page': self.default_page,
|
||||
}
|
||||
for filtr in self.iter_filters():
|
||||
settings['filter.{0}.active'.format(filtr.key)] = filtr.default_active
|
||||
settings['filter.{0}.verb'.format(filtr.key)] = filtr.default_verb
|
||||
settings['filter.{0}.value'.format(filtr.key)] = filtr.default_value
|
||||
|
||||
# If request has filter settings, grab those, then grab sort/pager
|
||||
# settings from request or session.
|
||||
if self.request_has_settings('filter'):
|
||||
self.update_filter_settings(settings, 'request')
|
||||
if self.request_has_settings('sort'):
|
||||
self.update_sort_settings(settings, 'request')
|
||||
else:
|
||||
self.update_sort_settings(settings, 'session')
|
||||
self.update_page_settings(settings)
|
||||
|
||||
# If request has no filter settings but does have sort settings, grab
|
||||
# those, then grab filter settings from session, then grab pager
|
||||
# settings from request or session.
|
||||
elif self.request_has_settings('sort'):
|
||||
self.update_sort_settings(settings, 'request')
|
||||
self.update_filter_settings(settings, 'session')
|
||||
self.update_page_settings(settings)
|
||||
|
||||
# NOTE: These next two are functionally equivalent, but are kept
|
||||
# separate to maintain the narrative...
|
||||
|
||||
# If request has no filter/sort settings but does have pager settings,
|
||||
# grab those, then grab filter/sort settings from session.
|
||||
elif self.request_has_settings('page'):
|
||||
self.update_page_settings(settings)
|
||||
self.update_filter_settings(settings, 'session')
|
||||
self.update_sort_settings(settings, 'session')
|
||||
|
||||
# If request has no settings, grab all from session.
|
||||
elif self.session_has_settings():
|
||||
self.update_filter_settings(settings, 'session')
|
||||
self.update_sort_settings(settings, 'session')
|
||||
self.update_page_settings(settings)
|
||||
|
||||
# If no settings were found in request or session, don't store result.
|
||||
else:
|
||||
store = False
|
||||
|
||||
# Maybe store settings for next time.
|
||||
if store:
|
||||
self.store_settings(settings)
|
||||
|
||||
# Update ourself and our filters, to reflect settings.
|
||||
if self.filterable:
|
||||
for filtr in self.iter_filters():
|
||||
filtr.active = settings['filter.{0}.active'.format(filtr.key)]
|
||||
filtr.verb = settings['filter.{0}.verb'.format(filtr.key)]
|
||||
filtr.value = settings['filter.{0}.value'.format(filtr.key)]
|
||||
if self.sortable:
|
||||
self.sortkey = settings['sortkey']
|
||||
self.sortdir = settings['sortdir']
|
||||
if self.pageable:
|
||||
self.pagesize = settings['pagesize']
|
||||
self.page = settings['page']
|
||||
|
||||
def request_has_settings(self, type_):
|
||||
"""
|
||||
Determine if the current request (GET query string) contains any
|
||||
filter/sort settings for the grid.
|
||||
"""
|
||||
if type_ == 'filter':
|
||||
for filtr in self.iter_filters():
|
||||
if filtr.key in self.request.GET:
|
||||
return True
|
||||
if 'filter' in self.request.GET: # user may be applying empty filters
|
||||
return True
|
||||
elif type_ == 'sort':
|
||||
for key in ['sortkey', 'sortdir']:
|
||||
if key in self.request.GET:
|
||||
return True
|
||||
elif type_ == 'page':
|
||||
for key in ['pagesize', 'page']:
|
||||
if key in self.request.GET:
|
||||
return True
|
||||
return False
|
||||
|
||||
def session_has_settings(self):
|
||||
"""
|
||||
Determine if the current session contains any settings for the grid.
|
||||
"""
|
||||
# Session should have all or nothing, so just check one key.
|
||||
return 'grid.{0}.sortkey'.format(self.key) in self.request.session
|
||||
|
||||
def get_setting(self, source, settings, key, coerce=lambda v: v, default=None):
|
||||
"""
|
||||
Get the effective value for a particular setting, preferring ``source``
|
||||
but falling back to existing ``settings`` and finally the ``default``.
|
||||
"""
|
||||
if source not in ('request', 'session'):
|
||||
raise ValueError("Invalid source identifier: {0}".format(repr(source)))
|
||||
|
||||
# If source is query string, try that first.
|
||||
if source == 'request':
|
||||
value = self.request.GET.get(key)
|
||||
if value is not None:
|
||||
try:
|
||||
value = coerce(value)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return value
|
||||
|
||||
# Or, if source is session, try that first.
|
||||
else:
|
||||
value = self.request.session.get('grid.{0}.{1}'.format(self.key, key))
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
# If source had nothing, try default/existing settings.
|
||||
value = settings.get(key)
|
||||
if value is not None:
|
||||
try:
|
||||
value = coerce(value)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return value
|
||||
|
||||
# Okay then, default it is.
|
||||
return default
|
||||
|
||||
def update_filter_settings(self, settings, source):
|
||||
"""
|
||||
Updates a settings dictionary according to filter settings data found
|
||||
in either the GET query string, or session storage.
|
||||
|
||||
:param settings: Dictionary of initial settings, which is to be updated.
|
||||
|
||||
:param source: String identifying the source to consult for settings
|
||||
data. Must be one of: ``('request', 'session')``.
|
||||
"""
|
||||
if not self.filterable:
|
||||
return
|
||||
|
||||
for filtr in self.iter_filters():
|
||||
prefix = 'filter.{0}'.format(filtr.key)
|
||||
|
||||
if source == 'request':
|
||||
# consider filter active if query string contains a value for it
|
||||
settings['{0}.active'.format(prefix)] = filtr.key in self.request.GET
|
||||
settings['{0}.verb'.format(prefix)] = self.get_setting(
|
||||
source, settings, '{0}.verb'.format(filtr.key), default='')
|
||||
settings['{0}.value'.format(prefix)] = self.get_setting(
|
||||
source, settings, filtr.key, default='')
|
||||
|
||||
else: # source = session
|
||||
settings['{0}.active'.format(prefix)] = self.get_setting(
|
||||
source, settings, '{0}.active'.format(prefix), default=False)
|
||||
settings['{0}.verb'.format(prefix)] = self.get_setting(
|
||||
source, settings, '{0}.verb'.format(prefix), default='')
|
||||
settings['{0}.value'.format(prefix)] = self.get_setting(
|
||||
source, settings, '{0}.value'.format(prefix), default='')
|
||||
|
||||
def update_sort_settings(self, settings, source):
|
||||
"""
|
||||
Updates a settings dictionary according to sort settings data found in
|
||||
either the GET query string, or session storage.
|
||||
|
||||
:param settings: Dictionary of initial settings, which is to be updated.
|
||||
|
||||
:param source: String identifying the source to consult for settings
|
||||
data. Must be one of: ``('request', 'session')``.
|
||||
"""
|
||||
if not self.sortable:
|
||||
return
|
||||
settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
|
||||
settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
|
||||
|
||||
def update_page_settings(self, settings):
|
||||
"""
|
||||
Updates a settings dictionary according to pager settings data found in
|
||||
either the GET query string, or session storage.
|
||||
|
||||
Note that due to how the actual pager functions, the effective settings
|
||||
will often come from *both* the request and session. This is so that
|
||||
e.g. the page size will remain constant (coming from the session) while
|
||||
the user jumps between pages (which only provides the single setting).
|
||||
|
||||
:param settings: Dictionary of initial settings, which is to be updated.
|
||||
"""
|
||||
if not self.pageable:
|
||||
return
|
||||
|
||||
pagesize = self.request.GET.get('pagesize')
|
||||
if pagesize is not None:
|
||||
if pagesize.isdigit():
|
||||
settings['pagesize'] = int(pagesize)
|
||||
else:
|
||||
pagesize = self.request.session.get('grid.{0}.pagesize'.format(self.key))
|
||||
if pagesize is not None:
|
||||
settings['pagesize'] = pagesize
|
||||
|
||||
page = self.request.GET.get('page')
|
||||
if page is not None:
|
||||
if page.isdigit():
|
||||
settings['page'] = page
|
||||
else:
|
||||
page = self.request.session.get('grid.{0}.page'.format(self.key))
|
||||
if page is not None:
|
||||
settings['page'] = page
|
||||
|
||||
def store_settings(self, settings):
|
||||
"""
|
||||
Store settings in current session, for next time.
|
||||
"""
|
||||
def store(key):
|
||||
self.request.session['grid.{0}.{1}'.format(self.key, key)] = settings[key]
|
||||
|
||||
if self.filterable:
|
||||
for filtr in self.iter_filters():
|
||||
store('filter.{0}.active'.format(filtr.key))
|
||||
store('filter.{0}.verb'.format(filtr.key))
|
||||
store('filter.{0}.value'.format(filtr.key))
|
||||
|
||||
if self.sortable:
|
||||
store('sortkey')
|
||||
store('sortdir')
|
||||
|
||||
if self.pageable:
|
||||
store('pagesize')
|
||||
store('page')
|
||||
|
||||
def filter_data(self, data):
|
||||
"""
|
||||
Filter and return the given data set, according to current settings.
|
||||
"""
|
||||
for filtr in self.iter_active_filters():
|
||||
data = filtr.filter(data)
|
||||
return data
|
||||
|
||||
def sort_data(self, data):
|
||||
"""
|
||||
Sort the given data set according to current settings, and return the
|
||||
result. Note that the default implementation does nothing.
|
||||
"""
|
||||
return data
|
||||
|
||||
def paginate_data(self, data):
|
||||
"""
|
||||
Paginate the given data set according to current settings, and return
|
||||
the result. Note that the default implementation does nothing.
|
||||
"""
|
||||
return data
|
||||
|
||||
def make_visible_data(self):
|
||||
"""
|
||||
Apply various settings to the raw data set, to produce a final data
|
||||
set. This will page / sort / filter as necessary, according to the
|
||||
grid's defaults and the current request etc.
|
||||
"""
|
||||
data = self.data
|
||||
if self.filterable:
|
||||
data = self.filter_data(data)
|
||||
if self.sortable:
|
||||
data = self.sort_data(data)
|
||||
if self.pageable:
|
||||
self.pager = self.paginate_data(data)
|
||||
data = self.pager
|
||||
return data
|
||||
|
||||
def render_complete(self, template='/newgrids/complete.mako', **kwargs):
|
||||
"""
|
||||
Render the complete grid, including filters.
|
||||
"""
|
||||
kwargs['grid'] = self
|
||||
return render(template, kwargs)
|
||||
|
||||
def render_grid(self, template='/newgrids/grid.mako', **kwargs):
|
||||
"""
|
||||
Render the grid to a Unicode string, using the specified template.
|
||||
Addition kwargs are passed along as context to the template.
|
||||
"""
|
||||
kwargs['grid'] = self
|
||||
kwargs['format_attrs'] = format_attrs
|
||||
return render(template, kwargs)
|
||||
|
||||
def render_filters(self, template='/newgrids/filters.mako', **kwargs):
|
||||
"""
|
||||
Render the filters to a Unicode string, using the specified template.
|
||||
Additional kwargs are passed along as context to the template.
|
||||
"""
|
||||
# Provide default data to filters form, so renderer can do some of the
|
||||
# work for us.
|
||||
data = {}
|
||||
for filtr in self.iter_active_filters():
|
||||
data['{0}.active'.format(filtr.key)] = filtr.active
|
||||
data['{0}.verb'.format(filtr.key)] = filtr.verb
|
||||
data[filtr.key] = filtr.value
|
||||
|
||||
form = filters.GridFiltersForm(self.request, self.filters, defaults=data)
|
||||
|
||||
kwargs['grid'] = self
|
||||
kwargs['form'] = filters.GridFiltersFormRenderer(form)
|
||||
return render(template, kwargs)
|
||||
|
||||
def get_div_attrs(self):
|
||||
"""
|
||||
Returns a properly-formatted set of attributes which will be applied to
|
||||
the parent ``<div>`` element which contains the grid, when the grid is
|
||||
rendered.
|
||||
"""
|
||||
classes = ['newgrid']
|
||||
if self.width == 'full':
|
||||
classes.append('full')
|
||||
return {'class_': ' '.join(classes),
|
||||
'data-url': self.request.current_route_url(_query=None),
|
||||
'data-permalink': self.request.current_route_url()}
|
||||
|
||||
def iter_visible_columns(self):
|
||||
"""
|
||||
Returns an iterator for all currently-visible columns.
|
||||
"""
|
||||
return iter(self.columns)
|
||||
|
||||
def column_header(self, column):
|
||||
"""
|
||||
Render a header (``<th>`` element) for a grid column.
|
||||
"""
|
||||
kwargs = {'c': column.label}
|
||||
if self.sortable and column.key in self.sorters:
|
||||
if column.key == self.sortkey:
|
||||
kwargs['class_'] = 'sortable sorted {0}'.format(self.sortdir)
|
||||
else:
|
||||
kwargs['class_'] = 'sortable'
|
||||
kwargs['data-sortkey'] = column.key
|
||||
kwargs['c'] = tags.link_to(column.label, '#')
|
||||
return HTML.tag('th', **kwargs)
|
||||
|
||||
@property
|
||||
def show_actions_column(self):
|
||||
"""
|
||||
Whether or not an "Actions" column should be rendered for the grid.
|
||||
"""
|
||||
return bool(self.main_actions or self.more_actions)
|
||||
|
||||
def render_actions(self, row):
|
||||
"""
|
||||
Returns the rendered contents of the 'actions' column for a given row.
|
||||
"""
|
||||
main_actions = filter(None, [self.render_action(a, row) for a in self.main_actions])
|
||||
more_actions = filter(None, [self.render_action(a, row) for a in self.more_actions])
|
||||
if more_actions:
|
||||
icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
|
||||
link = tags.link_to("More" + icon, '#', class_='more')
|
||||
main_actions.append(link + HTML.tag('div', class_='more', c=more_actions))
|
||||
# main_actions.append(tags.link_to("More" + icon + HTML.literal('').join(more_actions), '#', class_='more'))
|
||||
return HTML.literal('').join(main_actions)
|
||||
|
||||
def render_action(self, action, row):
|
||||
"""
|
||||
Renders an action menu item (link) for the given row.
|
||||
"""
|
||||
url = action.get_url(row)
|
||||
if url:
|
||||
if action.icon:
|
||||
icon = HTML.tag('span', class_='ui-icon ui-icon-{0}'.format(action.icon))
|
||||
return tags.link_to(icon + action.label, url)
|
||||
return tags.link_to(action.label, url)
|
||||
|
||||
def iter_rows(self):
|
||||
return []
|
||||
|
||||
def get_row_attrs(self, row, i):
|
||||
"""
|
||||
Returns a properly-formatted set of attributes which will be applied to
|
||||
the ``<tr>`` element for the given row. Note that ``i`` will be a
|
||||
1-based index value for the row within its table. The meaning of
|
||||
``row`` is basically not defined; it depends on the type of data the
|
||||
grid deals with.
|
||||
"""
|
||||
# attrs = {'class_': self.get_row_class(row, i)}
|
||||
# attrs = {}
|
||||
# return format_attrs(**attrs)
|
||||
return {}
|
||||
|
||||
# def get_row_class(self, row, i):
|
||||
# class_ = self.default_row_class(row, i)
|
||||
# if callable(self.extra_row_class):
|
||||
# extra = self.extra_row_class(row, i)
|
||||
# if extra:
|
||||
# class_ = '{0} {1}'.format(class_, extra)
|
||||
# return class_
|
||||
|
||||
# def checkbox(self, key):
|
||||
# """
|
||||
# Render a checkbox using the given key.
|
||||
# """
|
||||
# return tags.checkbox('checkbox-{0}-{1}'.format(self.key, key))
|
||||
|
||||
def get_cell_attrs(self, row, column):
|
||||
"""
|
||||
Returns a dictionary of HTML attributes which should be applied to the
|
||||
``<td>`` element in which the given row and column "intersect".
|
||||
"""
|
||||
return {}
|
||||
|
||||
def render_cell(self, row, column):
|
||||
return ''
|
||||
|
||||
def get_pagesize_options(self):
|
||||
# TODO: Make configurable or something...
|
||||
return [5, 10, 20, 50, 100]
|
||||
|
||||
|
||||
class GridColumn(object):
|
||||
"""
|
||||
Simple class to represent a column displayed within a grid table.
|
||||
|
||||
.. attribute:: key
|
||||
|
||||
Key for the column, within the context of the grid.
|
||||
|
||||
.. attribute:: label
|
||||
|
||||
Human-facing label for the column, i.e. displayed in the header.
|
||||
"""
|
||||
key = None
|
||||
label = None
|
||||
|
||||
|
||||
class GridAction(object):
|
||||
"""
|
||||
Represents an action available to a grid. This is used to construct the
|
||||
'actions' column when rendering the grid.
|
||||
"""
|
||||
|
||||
def __init__(self, key, label=None, url='#', icon=None):
|
||||
self.key = key
|
||||
self.label = label or key.capitalize()
|
||||
self.icon = icon
|
||||
self.url = url
|
||||
|
||||
def get_url(self, row):
|
||||
"""
|
||||
Returns an action URL for the given row.
|
||||
"""
|
||||
if callable(self.url):
|
||||
return self.url(row)
|
||||
return self.url
|
314
tailbone/newgrids/filters.py
Normal file
314
tailbone/newgrids/filters.py
Normal file
|
@ -0,0 +1,314 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2015 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Grid Filters
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from edbob.util import prettify
|
||||
|
||||
from rattail.util import OrderedDict
|
||||
from rattail.core import UNSPECIFIED
|
||||
|
||||
from pyramid_simpleform import Form
|
||||
from pyramid_simpleform.renderers import FormRenderer
|
||||
from webhelpers.html import HTML, tags
|
||||
|
||||
|
||||
class FilterRenderer(object):
|
||||
"""
|
||||
Base class for all filter renderers.
|
||||
"""
|
||||
|
||||
def render(self, value=None, **kwargs):
|
||||
"""
|
||||
Render the filter input element(s) as HTML. Default implementation
|
||||
uses a simple text input.
|
||||
"""
|
||||
name = self.filter.key
|
||||
return tags.text(name, value=value, id='filter.{0}.value'.format(name))
|
||||
|
||||
|
||||
class DefaultRenderer(FilterRenderer):
|
||||
"""
|
||||
Default / fallback renderer.
|
||||
"""
|
||||
|
||||
|
||||
class NumericRenderer(FilterRenderer):
|
||||
"""
|
||||
Input renderer for numeric fields.
|
||||
"""
|
||||
|
||||
|
||||
class GridFilter(object):
|
||||
"""
|
||||
Represents a filter available to a grid. This is used to construct the
|
||||
'filters' section when rendering the index page template.
|
||||
"""
|
||||
verbmap = {
|
||||
'equal': "equal to",
|
||||
'not_equal': "not equal to",
|
||||
'greater_than': "greater than",
|
||||
'greater_equal': "greater than or equal to",
|
||||
'less_than': "less than",
|
||||
'less_equal': "less than or equal to",
|
||||
'is_null': "is null",
|
||||
'is_not_null': "is not null",
|
||||
'contains': "contains",
|
||||
'does_not_contain': "does not contain",
|
||||
}
|
||||
|
||||
def __init__(self, key, label=None, verbs=None, renderer=None,
|
||||
default_active=False, default_verb=None, default_value=None):
|
||||
self.key = key
|
||||
self.label = label or prettify(key)
|
||||
self.verbs = verbs or self.default_verbs()
|
||||
self.renderer = renderer or DefaultRenderer()
|
||||
self.renderer.filter = self
|
||||
self.default_active = default_active
|
||||
self.default_verb = default_verb
|
||||
self.default_value = default_value
|
||||
|
||||
def __repr__(self):
|
||||
return "GridFilter({0})".format(repr(self.key))
|
||||
|
||||
def default_verbs(self):
|
||||
"""
|
||||
Returns the set of verbs which will be used by default, i.e. unless
|
||||
overridden by constructor args etc.
|
||||
"""
|
||||
return ['equal', 'not_equal', 'is_null', 'is_not_null']
|
||||
|
||||
def filter(self, data, verb=None, value=UNSPECIFIED):
|
||||
"""
|
||||
Filter the given data set according to a verb/value pair. If no verb
|
||||
and/or value is specified by the caller, the filter will use its own
|
||||
current verb/value by default.
|
||||
"""
|
||||
verb = verb or self.verb
|
||||
value = value if value is not UNSPECIFIED else self.value
|
||||
filtr = getattr(self, 'filter_{0}'.format(verb), None)
|
||||
if not filtr:
|
||||
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
|
||||
return filtr(data, value)
|
||||
|
||||
def render(self, **kwargs):
|
||||
kwargs['filter'] = self
|
||||
return self.renderer.render(**kwargs)
|
||||
|
||||
|
||||
class AlchemyGridFilter(GridFilter):
|
||||
"""
|
||||
Base class for SQLAlchemy grid filters.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.model_property = kwargs.pop('model_property')
|
||||
self.model_class = self.model_property.parent.class_
|
||||
self.model_column = getattr(self.model_class, self.model_property.key)
|
||||
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
def filter_equal(self, query, value):
|
||||
"""
|
||||
Filter data with an equal ('=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column == value)
|
||||
|
||||
def filter_not_equal(self, query, value):
|
||||
"""
|
||||
Filter data with a not eqaul ('!=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
# When saying something is 'not equal' to something else, we must also
|
||||
# include things which are nothing at all, in our result set.
|
||||
return query.filter(sa.or_(
|
||||
self.model_column == None,
|
||||
self.model_column != value,
|
||||
))
|
||||
|
||||
def filter_is_null(self, query, value):
|
||||
"""
|
||||
Filter data with an 'IS NULL' query. Note that this filter does not
|
||||
use the value for anything.
|
||||
"""
|
||||
return query.filter(self.model_column == None)
|
||||
|
||||
def filter_is_not_null(self, query, value):
|
||||
"""
|
||||
Filter data with an 'IS NOT NULL' query. Note that this filter does
|
||||
not use the value for anything.
|
||||
"""
|
||||
return query.filter(self.model_column != None)
|
||||
|
||||
def filter_greater_than(self, query, value):
|
||||
"""
|
||||
Filter data with a greater than ('>') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column > value)
|
||||
|
||||
def filter_greater_equal(self, query, value):
|
||||
"""
|
||||
Filter data with a greater than or equal ('>=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column >= value)
|
||||
|
||||
def filter_less_than(self, query, value):
|
||||
"""
|
||||
Filter data with a less than ('<') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column < value)
|
||||
|
||||
def filter_less_equal(self, query, value):
|
||||
"""
|
||||
Filter data with a less than or equal ('<=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column <= value)
|
||||
|
||||
|
||||
class AlchemyStringFilter(AlchemyGridFilter):
|
||||
"""
|
||||
String filter for SQLAlchemy.
|
||||
"""
|
||||
|
||||
def default_verbs(self):
|
||||
"""
|
||||
Expose contains / does-not-contain verbs in addition to core.
|
||||
"""
|
||||
return ['contains', 'does_not_contain',
|
||||
'equal', 'not_equal', 'is_null', 'is_not_null']
|
||||
|
||||
def filter_contains(self, query, value):
|
||||
"""
|
||||
Filter data with a full 'ILIKE' query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(self.model_column.ilike('%{0}%'.format(value)))
|
||||
|
||||
def filter_does_not_contain(self, query, value):
|
||||
"""
|
||||
Filter data with a full 'NOT ILIKE' query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
# When saying something is 'not like' something else, we must also
|
||||
# include things which are nothing at all, in our result set.
|
||||
return query.filter(sa.or_(
|
||||
self.model_column == None,
|
||||
~self.model_column.ilike('%{0}%'.format(value)),
|
||||
))
|
||||
|
||||
|
||||
class AlchemyNumericFilter(AlchemyGridFilter):
|
||||
"""
|
||||
Numeric filter for SQLAlchemy.
|
||||
"""
|
||||
|
||||
def default_verbs(self):
|
||||
"""
|
||||
Expose greater-than / less-than verbs in addition to core.
|
||||
"""
|
||||
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
||||
'less_than', 'less_equal', 'is_null', 'is_not_null']
|
||||
|
||||
|
||||
class GridFilterSet(OrderedDict):
|
||||
"""
|
||||
Collection class for :class:`GridFilter` instances.
|
||||
"""
|
||||
|
||||
|
||||
class GridFiltersForm(Form):
|
||||
"""
|
||||
Form for grid filters.
|
||||
"""
|
||||
|
||||
def __init__(self, request, filters, *args, **kwargs):
|
||||
super(GridFiltersForm, self).__init__(request, *args, **kwargs)
|
||||
self.filters = filters
|
||||
|
||||
def iter_filters(self):
|
||||
return self.filters.itervalues()
|
||||
|
||||
|
||||
class GridFiltersFormRenderer(FormRenderer):
|
||||
"""
|
||||
Renderer for :class:`GridFiltersForm` instances.
|
||||
"""
|
||||
|
||||
@property
|
||||
def filters(self):
|
||||
return self.form.filters
|
||||
|
||||
def iter_filters(self):
|
||||
return self.form.iter_filters()
|
||||
|
||||
def tag(self, *args, **kwargs):
|
||||
"""
|
||||
Convenience method which passes all args to the
|
||||
:func:`webhelpers:webhelpers.HTML.tag()` function.
|
||||
"""
|
||||
return HTML.tag(*args, **kwargs)
|
||||
|
||||
# TODO: This seems hacky..?
|
||||
def checkbox(self, name, checked=None, **kwargs):
|
||||
"""
|
||||
Custom checkbox implementation.
|
||||
"""
|
||||
if name.endswith('-active'):
|
||||
return tags.checkbox(name, checked=checked, **kwargs)
|
||||
if checked is None:
|
||||
checked = False
|
||||
return super(GridFiltersFormRenderer, self).checkbox(name, checked=checked, **kwargs)
|
||||
|
||||
def filter_verb(self, filtr):
|
||||
"""
|
||||
Render the verb selection dropdown for the given filter.
|
||||
"""
|
||||
options = [(v, filtr.verbmap.get(v, "unknown verb '{0}'".format(v)))
|
||||
for v in filtr.verbs]
|
||||
return self.select('{0}.verb'.format(filtr.key), options, class_='verb')
|
||||
|
||||
def filter_value(self, filtr):
|
||||
"""
|
||||
Render the value input element(s) for the filter.
|
||||
"""
|
||||
# TODO: This surely needs some work..?
|
||||
return HTML.tag('div', class_='value', c=filtr.render(value=self.value(filtr.key)))
|
|
@ -64,6 +64,16 @@ body > #body-wrapper {
|
|||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* context menu
|
||||
******************************/
|
||||
|
||||
#context-menu {
|
||||
float: right;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Panels
|
||||
******************************/
|
||||
|
|
190
tailbone/static/css/newgrids.css
Normal file
190
tailbone/static/css/newgrids.css
Normal 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
243
tailbone/static/js/jquery.ui.tailbone.js
vendored
Normal 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 );
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
|
|
16
tailbone/templates/master/create.mako
Normal file
16
tailbone/templates/master/create.mako
Normal 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 -->
|
19
tailbone/templates/master/edit.mako
Normal file
19
tailbone/templates/master/edit.mako
Normal 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 -->
|
22
tailbone/templates/master/index.mako
Normal file
22
tailbone/templates/master/index.mako
Normal 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}
|
19
tailbone/templates/master/view.mako
Normal file
19
tailbone/templates/master/view.mako
Normal 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 -->
|
7
tailbone/templates/newgrids/complete.mako
Normal file
7
tailbone/templates/newgrids/complete.mako
Normal 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 -->
|
31
tailbone/templates/newgrids/filters.mako
Normal file
31
tailbone/templates/newgrids/filters.mako
Normal 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 -->
|
50
tailbone/templates/newgrids/grid.mako
Normal file
50
tailbone/templates/newgrids/grid.mako
Normal 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
|
||||
${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev', partial=1)}
|
||||
</p>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
|
@ -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()}
|
|
@ -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
449
tailbone/views/master.py
Normal 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))
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue