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
26 changed files with 2296 additions and 94 deletions
|
@ -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…
Add table
Add a link
Reference in a new issue