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

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)