tailbone/tailbone/views/master.py
2017-08-03 17:06:26 -05:00

1725 lines
68 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Model Master View
"""
from __future__ import unicode_literals, absolute_import
import six
import sqlalchemy as sa
from sqlalchemy import orm
import sqlalchemy_continuum as continuum
from rattail.db.continuum import model_transaction_query
from rattail.util import prettify
from rattail.time import localtime
import formalchemy as fa
from pyramid import httpexceptions
from pyramid.renderers import get_renderer, render_to_response, render
from webhelpers2.html import HTML, tags
from tailbone import forms, grids
from tailbone.views import View
class MasterView(View):
"""
Base "master" view class. All model master views should derive from this.
"""
filterable = True
pageable = True
checkboxes = False
listable = True
creatable = True
viewable = True
editable = True
deletable = True
bulk_deletable = False
mergeable = False
downloadable = False
supports_mobile = False
mobile_creatable = False
mobile_filterable = False
listing = False
creating = False
viewing = False
editing = False
deleting = False
has_pk_fields = False
row_attrs = {}
cell_attrs = {}
grid_index = None
use_index_links = False
has_versions = False
# ROW-RELATED ATTRS FOLLOW:
has_rows = False
model_row_class = None
rows_filterable = True
rows_sortable = True
rows_viewable = True
rows_creatable = False
rows_editable = False
rows_deletable = False
rows_deletable_speedbump = False
rows_bulk_deletable = False
rows_default_pagesize = 20
mobile_rows_creatable = False
mobile_rows_filterable = False
mobile_rows_viewable = False
mobile_rows_editable = False
@property
def Session(self):
"""
SQLAlchemy scoped session to use when querying the database. Defaults
to ``tailbone.db.Session``.
"""
from tailbone.db import Session
return Session
##############################
# 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 rendered grid only. Otherwise
returns the full page.
"""
self.listing = True
grid = self.make_grid()
# If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true':
return self.redirect(self.request.current_route_url(_query=None))
# Stash some grid stats, for possible use when generating URLs.
if grid.pageable and hasattr(grid, 'pager'):
self.first_visible_grid_index = grid.pager.first_item
# Return grid only, if partial page was requested.
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 mobile_index(self):
"""
Mobile "home" page for the data model
"""
self.listing = True
grid = self.make_mobile_grid()
return self.render_to_response('index', {'grid': grid}, mobile=True)
@classmethod
def get_mobile_grid_key(cls):
"""
Must return a unique "config key" for the mobile grid, for sort/filter
purposes etc. (It need only be unique among *mobile* grids.) Instead
of overriding this, you can set :attr:`mobile_grid_key`. Default is
the value returned by :meth:`get_route_prefix()`.
"""
if hasattr(cls, 'mobile_grid_key'):
return cls.mobile_grid_key
return 'mobile.{}'.format(cls.get_route_prefix())
def get_mobile_data(self, session=None):
"""
Must return the "raw" / full data set for the mobile grid. This data
should *not* yet be sorted or filtered in any way; that happens later.
Default is the value returned by :meth:`get_data()`, in which case all
records visible in the traditional view, are visible in mobile too.
"""
return self.get_data(session=session)
def make_mobile_filters(self):
"""
Returns a set of filters for the mobile grid, if applicable.
"""
def make_mobile_row_filters(self):
"""
Returns a set of filters for the mobile row grid, if applicable.
"""
def mobile_listitem_field(self):
"""
Must return a FormAlchemy field to be appended to grid, or ``None`` if
none is desired.
"""
return fa.Field('listitem', value=lambda obj: obj,
renderer=self.mobile_listitem_renderer())
def mobile_listitem_renderer(self):
"""
Must return a FormAlchemy field renderer callable for the mobile grid's
list item field.
"""
master = self
class ListItemRenderer(fa.FieldRenderer):
def render_readonly(self, **kwargs):
obj = self.raw_value
if obj is None:
return ''
title = master.get_instance_title(obj)
url = master.get_action_url('view', obj, mobile=True)
return tags.link_to(title, url)
return ListItemRenderer
def mobile_row_listitem_renderer(self):
"""
Must return a FormAlchemy field renderer callable for the mobile row
grid's list item field.
"""
master = self
class ListItemRenderer(fa.FieldRenderer):
def render_readonly(self, **kwargs):
return master.render_mobile_row_listitem(self.raw_value, **kwargs)
return ListItemRenderer
def create(self):
"""
View for creating a new model record.
"""
self.creating = True
form = self.make_form(self.get_model_class())
if self.request.method == 'POST':
if self.validate_form(form):
# let save_create_form() return alternate object if necessary
obj = self.save_create_form(form) or form.fieldset.model
self.after_create(obj)
self.flash_after_create(obj)
return self.redirect_after_create(obj)
return self.render_to_response('create', {'form': form})
def mobile_create(self):
"""
Mobile view for creating a new primary object
"""
self.creating = True
form = self.make_mobile_form(self.get_model_class())
if self.request.method == 'POST':
if form.validate():
# let save_create_form() return alternate object if necessary
obj = self.save_create_form(form) or form.fieldset.model
self.after_create(obj)
self.flash_after_create(obj)
return self.redirect_after_create(obj, mobile=True)
return self.render_to_response('create', {'form': form}, mobile=True)
def flash_after_create(self, obj):
self.request.session.flash("{} has been created: {}".format(
self.get_model_title(), self.get_instance_title(obj)))
def save_create_form(self, form):
self.before_create(form)
form.save()
def redirect_after_create(self, instance, mobile=False):
return self.redirect(self.get_action_url('view', instance, mobile=mobile))
def view(self, instance=None):
"""
View for viewing details of an existing model record.
"""
self.viewing = True
if instance is None:
instance = self.get_instance()
form = self.make_form(instance)
if self.has_rows:
# must make grid prior to redirecting from filter reset, b/c the
# grid will detect the filter reset request and store defaults in
# the session, that way redirect will then show The Right Thing
grid = self.make_row_grid(instance=instance)
# If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true':
return self.redirect(self.request.current_route_url(_query=None))
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
context = {
'instance': instance,
'instance_title': self.get_instance_title(instance),
'instance_editable': self.editable_instance(instance),
'instance_deletable': self.deletable_instance(instance),
'form': form,
}
if self.has_rows:
context['rows_grid'] = grid.render_complete(allow_save_defaults=False,
tools=self.make_row_grid_tools(instance))
return self.render_to_response('view', context)
def versions(self):
"""
View to list version history for an object.
"""
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
grid = self.make_version_grid(instance=instance)
# return grid only, if partial page was requested
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('versions', {
'instance': instance,
'instance_title': instance_title,
'instance_url': self.get_action_url('view', instance),
'grid': grid,
})
@classmethod
def get_version_grid_key(cls):
"""
Returns the unique key to be used for the version grid, for caching
sort/filter options etc.
"""
if hasattr(cls, 'version_grid_key'):
return cls.version_grid_key
return '{}.history'.format(cls.get_route_prefix())
def get_version_data(self, instance):
"""
Generate the base data set for the version grid.
"""
model_class = self.get_model_class()
transaction_class = continuum.transaction_class(model_class)
query = model_transaction_query(self.Session(), instance, model_class,
child_classes=self.normalize_version_child_classes())
return query.order_by(transaction_class.issued_at.desc())
def get_version_child_classes(self):
"""
If applicable, should return a list of child classes which should be
considered when querying for version history of an object.
"""
return []
def normalize_version_child_classes(self):
classes = []
for cls in self.get_version_child_classes():
if not isinstance(cls, tuple):
cls = (cls, 'uuid', 'uuid')
elif len(cls) == 2:
cls = tuple([cls[0], cls[1], 'uuid'])
classes.append(cls)
return classes
def view_version(self):
"""
View showing diff details of a particular object version.
"""
instance = self.get_instance()
model_class = self.get_model_class()
Transaction = continuum.transaction_class(model_class)
transactions = model_transaction_query(self.Session(), instance, model_class,
child_classes=self.normalize_version_child_classes())
transaction_id = self.request.matchdict['txnid']
transaction = transactions.filter(Transaction.id == transaction_id).first()
if not transaction:
return self.notfound()
older = transactions.filter(Transaction.issued_at <= transaction.issued_at)\
.filter(Transaction.id != transaction_id)\
.order_by(Transaction.issued_at.desc())\
.first()
newer = transactions.filter(Transaction.issued_at >= transaction.issued_at)\
.filter(Transaction.id != transaction_id)\
.order_by(Transaction.issued_at)\
.first()
instance_title = self.get_instance_title(instance)
return self.render_to_response('view_version', {
'instance': instance,
'instance_title': "{} (history)".format(instance_title),
'instance_title_normal': instance_title,
'instance_url': self.get_action_url('versions', instance),
'transaction': transaction,
'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True),
'versions': self.get_relevant_versions(transaction, instance),
'previous_transaction': older,
'next_transaction': newer,
'title_for_version': self.title_for_version,
'fields_for_version': self.fields_for_version,
'continuum': continuum,
})
def title_for_version(self, version):
cls = continuum.parent_class(version.__class__)
return cls.get_model_title()
def fields_for_version(self, version):
mapper = orm.class_mapper(version.__class__)
fields = sorted(mapper.columns.keys())
fields.remove('transaction_id')
fields.remove('end_transaction_id')
fields.remove('operation_type')
return fields
def get_relevant_versions(self, transaction, instance):
versions = []
version_cls = self.get_model_version_class()
query = self.Session.query(version_cls)\
.filter(version_cls.transaction == transaction)\
.filter(version_cls.uuid == instance.uuid)
versions.extend(query.all())
for cls, foreign_attr, primary_attr in self.normalize_version_child_classes():
version_cls = continuum.version_class(cls)
query = self.Session.query(version_cls)\
.filter(version_cls.transaction == transaction)\
.filter(getattr(version_cls, foreign_attr) == getattr(instance, primary_attr))
versions.extend(query.all())
return versions
def mobile_view(self):
"""
Mobile view for displaying a single object's details
"""
self.viewing = True
instance = self.get_instance()
form = self.make_mobile_form(instance)
context = {
'instance': instance,
'instance_title': self.get_instance_title(instance),
# 'instance_editable': self.editable_instance(instance),
# 'instance_deletable': self.deletable_instance(instance),
'form': form,
}
if self.has_rows:
context['model_row_class'] = self.model_row_class
context['grid'] = self.make_mobile_row_grid(instance=instance)
return self.render_to_response('view', context, mobile=True)
def make_mobile_form(self, instance, **kwargs):
"""
Make a FormAlchemy-based form for use with mobile CRUD views
"""
fieldset = self.make_fieldset(instance)
self.preconfigure_mobile_fieldset(fieldset)
self.configure_mobile_fieldset(fieldset)
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if self.creating:
kwargs.setdefault('cancel_url', self.get_index_url(mobile=True))
else:
kwargs.setdefault('cancel_url', self.get_action_url('view', instance, mobile=True))
factory = kwargs.pop('factory', forms.AlchemyForm)
kwargs.setdefault('session', self.Session())
form = factory(self.request, fieldset, **kwargs)
form.readonly = self.viewing
return form
def preconfigure_mobile_row_fieldset(self, fieldset):
self._preconfigure_row_fieldset(fieldset)
def configure_mobile_row_fieldset(self, fieldset):
self.configure_row_fieldset(fieldset)
def make_mobile_row_form(self, row, **kwargs):
"""
Make a form for use with mobile CRUD views, for the given row object.
"""
fieldset = self.make_fieldset(row)
self.preconfigure_mobile_row_fieldset(fieldset)
self.configure_mobile_row_fieldset(fieldset)
kwargs.setdefault('session', self.Session())
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if 'cancel_url' not in kwargs:
kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(row), mobile=True)
factory = kwargs.pop('factory', forms.AlchemyForm)
form = factory(self.request, fieldset, **kwargs)
form.readonly = self.viewing
return form
def preconfigure_mobile_fieldset(self, fieldset):
self._preconfigure_fieldset(fieldset)
def configure_mobile_fieldset(self, fieldset):
"""
Configure the given mobile fieldset.
"""
self.configure_fieldset(fieldset)
def get_mobile_row_data(self, parent):
return self.get_row_data(parent)
def mobile_row_listitem_field(self):
"""
Must return a FormAlchemy field to be appended to row grid, or ``None``
if none is desired.
"""
return fa.Field('listitem', value=lambda obj: obj,
renderer=self.mobile_row_listitem_renderer())
def mobile_row_route_url(self, route_name, **kwargs):
route_name = 'mobile.{}.{}'.format(self.get_row_route_prefix(), route_name)
return self.request.route_url(route_name, **kwargs)
def mobile_view_row(self):
"""
Mobile view for row items
"""
self.viewing = True
row = self.get_row_instance()
parent = self.get_parent(row)
form = self.make_mobile_row_form(row)
context = {
'row': row,
'parent_instance': parent,
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent, mobile=True),
'instance': row,
'instance_title': self.get_row_instance_title(row),
'instance_editable': self.row_editable(row),
'parent_model_title': self.get_model_title(),
'form': form,
}
return self.render_to_response('view_row', context, mobile=True)
def make_default_row_grid_tools(self, obj):
if self.rows_creatable:
link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
self.get_action_url('create_row', obj))
return HTML.tag('p', c=link)
def make_row_grid_tools(self, obj):
return self.make_default_row_grid_tools(obj)
def get_effective_row_query(self):
"""
Convenience method which returns the "effective" query for the master
grid, filtered and sorted to match what would show on the UI, but not
paged etc.
"""
parent = self.get_instance()
grid = self.make_row_grid(instance=parent, sortable=False, pageable=False,
main_actions=[])
return grid._fa_grid.rows
def get_row_data(self, instance):
"""
Generate the base data set for a rows grid.
"""
raise NotImplementedError
@classmethod
def get_row_route_prefix(cls):
"""
Route prefix specific to the row-level views for a batch, e.g.
``'vendorcatalogs.rows'``.
"""
return "{}.rows".format(cls.get_route_prefix())
@classmethod
def get_row_url_prefix(cls):
"""
Returns a prefix which (by default) applies to all URLs provided by the
master view class, for "row" views, e.g. '/products/rows'.
"""
return getattr(cls, 'row_url_prefix', '{}/rows'.format(cls.get_url_prefix()))
@classmethod
def get_row_permission_prefix(cls):
"""
Permission prefix specific to the row-level data for this batch type,
e.g. ``'vendorcatalogs.rows'``.
"""
return "{}.rows".format(cls.get_permission_prefix())
def row_edit_action_url(self, row, i):
return self.get_row_action_url('edit', row)
def row_delete_action_url(self, row, i):
return self.get_row_action_url('delete', row)
def row_grid_row_attrs(self, row, i):
return {}
@classmethod
def get_row_model_title(cls):
if hasattr(cls, 'row_model_title'):
return cls.row_model_title
return "{} Row".format(cls.get_model_title())
@classmethod
def get_row_model_title_plural(cls):
return "{} Rows".format(cls.get_model_title())
def view_index(self):
"""
View a record according to its grid index.
"""
try:
index = int(self.request.GET['index'])
except KeyError, ValueError:
return self.redirect(self.get_index_url())
if index < 1:
return self.redirect(self.get_index_url())
data = self.get_effective_data()
try:
instance = data[index-1]
except IndexError:
return self.redirect(self.get_index_url())
self.grid_index = index
if hasattr(data, 'count'):
self.grid_count = data.count()
else:
self.grid_count = len(data)
return self.view(instance)
def edit(self):
"""
View for editing an existing model record.
"""
self.editing = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
if not self.editable_instance(instance):
self.request.session.flash("Edit is not permitted for {}: {}".format(
self.get_model_title(), instance_title), 'error')
return self.redirect(self.get_action_url('view', instance))
form = self.make_form(instance)
if self.request.method == 'POST':
if self.validate_form(form):
self.save_edit_form(form)
# note we must fetch new instance title, in case it changed
self.request.session.flash("{} has been updated: {}".format(
self.get_model_title(), self.get_instance_title(instance)))
return self.redirect_after_edit(instance)
return self.render_to_response('edit', {
'instance': instance,
'instance_title': instance_title,
'instance_deletable': self.deletable_instance(instance),
'form': form})
def validate_form(self, form):
return form.validate()
def save_edit_form(self, form):
self.save_form(form)
self.after_edit(form.fieldset.model)
def redirect_after_edit(self, instance):
return self.redirect(self.get_action_url('view', instance))
def delete(self):
"""
View for deleting an existing model record.
"""
if not self.deletable:
raise httpexceptions.HTTPForbidden()
self.deleting = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
if not self.deletable_instance(instance):
self.request.session.flash("Deletion is not permitted for {}: {}".format(
self.get_model_title(), instance_title), 'error')
return self.redirect(self.get_action_url('view', instance))
form = self.make_form(instance)
# TODO: Add better validation, ideally CSRF etc.
if self.request.method == 'POST':
# Let derived classes prep for (or cancel) deletion.
result = self.before_delete(instance)
if isinstance(result, httpexceptions.HTTPException):
return result
self.delete_instance(instance)
self.request.session.flash("{} has been deleted: {}".format(
self.get_model_title(), instance_title))
return self.redirect(self.get_after_delete_url(instance))
form.readonly = True
return self.render_to_response('delete', {
'instance': instance,
'instance_title': instance_title,
'form': form})
def bulk_delete(self):
"""
Delete all records matching the current grid query
"""
if self.request.method == 'POST':
query = self.get_effective_query(sortable=False)
count = query.count()
self.bulk_delete_objects(query)
self.request.session.flash("Deleted {:,d} {}".format(count, self.get_model_title_plural()))
else:
self.request.session.flash("Sorry, you must POST to do a bulk delete operation")
return self.redirect(self.get_index_url())
def bulk_delete_objects(self, query):
# TODO: sometimes the first makes sense, and would be preferred for
# efficiency's sake. might even need to add progress to latter?
# query.delete(synchronize_session=False)
for obj in query:
self.Session.delete(obj)
def get_merge_fields(self):
if hasattr(self, 'merge_fields'):
return self.merge_fields
mapper = orm.class_mapper(self.get_model_class())
return mapper.columns.keys()
def get_merge_coalesce_fields(self):
if hasattr(self, 'merge_coalesce_fields'):
return self.merge_coalesce_fields
return []
def get_merge_additive_fields(self):
if hasattr(self, 'merge_additive_fields'):
return self.merge_additive_fields
return []
def merge(self):
"""
Preview and execute a merge of two records.
"""
object_to_remove = object_to_keep = None
if self.request.method == 'POST':
uuids = self.request.POST.get('uuids', '').split(',')
if len(uuids) == 2:
object_to_remove = self.Session.query(self.get_model_class()).get(uuids[0])
object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1])
if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes':
msg = six.text_type(object_to_remove)
try:
self.validate_merge(object_to_remove, object_to_keep)
except Exception as error:
self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error')
else:
self.merge_objects(object_to_remove, object_to_keep)
self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
return self.redirect(self.get_action_url('view', object_to_keep))
if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep:
return self.redirect(self.get_index_url())
remove = self.get_merge_data(object_to_remove)
keep = self.get_merge_data(object_to_keep)
return self.render_to_response('merge', {'object_to_remove': object_to_remove,
'object_to_keep': object_to_keep,
'view_url': lambda obj: self.get_action_url('view', obj),
'merge_fields': self.get_merge_fields(),
'remove_data': remove,
'keep_data': keep,
'resulting_data': self.get_merge_resulting_data(remove, keep)})
def validate_merge(self, removing, keeping):
"""
If applicable, your view should override this in order to confirm that
the requested merge is valid, in your context. If it is not - for *any
reason* - you should raise an exception; the type does not matter.
"""
def get_merge_data(self, obj):
raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__))
def get_merge_resulting_data(self, remove, keep):
result = dict(keep)
for field in self.get_merge_coalesce_fields():
if remove[field] and not keep[field]:
result[field] = remove[field]
for field in self.get_merge_additive_fields():
if isinstance(keep[field], (list, tuple)):
result[field] = sorted(set(remove[field] + keep[field]))
else:
result[field] = remove[field] + keep[field]
return result
def merge_objects(self, removing, keeping):
"""
Merge the two given objects. You should probably override this;
default behavior is merely to delete the 'removing' object.
"""
self.Session.delete(removing)
##############################
# Core Stuff
##############################
@classmethod
def get_model_class(cls, error=True):
"""
Returns the data model class for which the master view exists.
"""
if not hasattr(cls, 'model_class') and error:
raise NotImplementedError("You must define the `model_class` for: {}".format(cls))
return getattr(cls, 'model_class', None)
@classmethod
def get_model_version_class(cls):
"""
Returns the version class for the master model class.
"""
return continuum.version_class(cls.get_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.
"""
if hasattr(cls, 'normalized_model_name'):
return cls.normalized_model_name
return 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.
"""
if hasattr(cls, 'model_title'):
return cls.model_title
return cls.get_model_class().get_model_title()
@classmethod
def get_model_title_plural(cls):
"""
Return a "humanized" (and plural) version of the model name, for
display in templates.
"""
if hasattr(cls, 'model_title_plural'):
return cls.model_title_plural
try:
return cls.get_model_class().get_model_title_plural()
except (NotImplementedError, AttributeError):
return '{}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, mobile=False, **kwargs):
"""
Returns the master view's index URL.
"""
route = self.get_route_prefix()
if mobile:
route = 'mobile.{}'.format(route)
return self.request.route_url(route)
@classmethod
def get_index_title(cls):
"""
Returns the title for the index page.
"""
return getattr(cls, 'index_title', cls.get_model_title_plural())
def get_action_url(self, action, instance, mobile=False, **kwargs):
"""
Generate a URL for the given action on the given instance
"""
kw = self.get_action_route_kwargs(instance)
kw.update(kwargs)
route_prefix = self.get_route_prefix()
if mobile:
route_prefix = 'mobile.{}'.format(route_prefix)
return self.request.route_url('{}.{}'.format(route_prefix, action), **kw)
def render_to_response(self, template, data, mobile=False):
"""
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.
"""
context = {
'master': self,
'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_title': self.get_index_title(),
'index_url': self.get_index_url(mobile=mobile),
'action_url': self.get_action_url,
'grid_index': self.grid_index,
}
if self.grid_index:
context['grid_count'] = self.grid_count
if self.has_rows:
context['row_route_prefix'] = self.get_row_route_prefix()
context['row_permission_prefix'] = self.get_row_permission_prefix()
context['row_model_title'] = self.get_row_model_title()
context['row_model_title_plural'] = self.get_row_model_title_plural()
context['row_action_url'] = self.get_row_action_url
context.update(data)
context.update(self.template_kwargs(**context))
if hasattr(self, 'template_kwargs_{}'.format(template)):
context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
if hasattr(self, 'mobile_template_kwargs_{}'.format(template)):
context.update(getattr(self, 'mobile_template_kwargs_{}'.format(template))(**context))
# First try the template path most specific to the view.
if mobile:
mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template)
else:
mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
try:
return render_to_response(mako_path, context, request=self.request)
except IOError:
# Failing that, try one or more fallback templates.
for fallback in self.get_fallback_templates(template, mobile=mobile):
try:
return render_to_response(fallback, context, request=self.request)
except IOError:
pass
# If we made it all the way here, we found no templates at all, in
# which case re-attempt the first and let that error raise on up.
return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request)
# TODO: merge this logic with render_to_response()
def render(self, template, data):
"""
Render the given template with the given context data.
"""
context = {
'master': self,
'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_title': self.get_index_title(),
'index_url': self.get_index_url(),
'action_url': self.get_action_url,
}
context.update(data)
# First try the template path most specific to the view.
try:
return render('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request)
except IOError:
# Failing that, try one or more fallback templates.
for fallback in self.get_fallback_templates(template):
try:
return render(fallback, context, request=self.request)
except IOError:
pass
# If we made it all the way here, we found no templates at all, in
# which case re-attempt the first and let that error raise on up.
return render('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request)
def get_fallback_templates(self, template, mobile=False):
if mobile:
return ['/mobile/master/{}.mako'.format(template)]
return ['/master/{}.mako'.format(template)]
def template_kwargs(self, **kwargs):
"""
Supplement the template context, for all views.
"""
return kwargs
##############################
# Grid Stuff
##############################
@classmethod
def get_grid_key(cls):
"""
Returns the unique key to be used for the grid, for caching sort/filter
options etc.
"""
if hasattr(cls, 'grid_key'):
return cls.grid_key
# default previously came from cls.get_normalized_model_name() but this is hopefully better
return cls.get_route_prefix()
def get_grid_actions(self):
main, more = self.get_main_actions(), self.get_more_actions()
if len(more) == 1:
main, more = main + more, []
return main, more
def get_row_attrs(self, row, i):
"""
Returns a dict of HTML attributes which is to be applied to the row's
``<tr>`` element. 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.
"""
if callable(self.row_attrs):
attrs = self.row_attrs(row, i)
else:
attrs = dict(self.row_attrs)
if self.mergeable:
attrs['data-uuid'] = row.uuid
return attrs
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".
"""
if callable(self.cell_attrs):
return self.cell_attrs(row, column)
return self.cell_attrs
def get_main_actions(self):
"""
Return a list of 'main' actions for the grid.
"""
actions = []
prefix = self.get_permission_prefix()
if self.viewable and self.request.has_perm('{}.view'.format(prefix)):
url = self.get_view_index_url if self.use_index_links else None
actions.append(self.make_action('view', icon='zoomin', url=url))
return actions
def get_view_index_url(self, row, i):
route = '{}.view_index'.format(self.get_route_prefix())
return '{}?index={}'.format(self.request.route_url(route), self.first_visible_grid_index + i - 1)
def get_more_actions(self):
"""
Return a list of 'more' actions for the grid.
"""
actions = []
prefix = self.get_permission_prefix()
if self.editable and self.request.has_perm('{}.edit'.format(prefix)):
actions.append(self.make_action('edit', icon='pencil', url=self.default_edit_url))
if self.deletable and self.request.has_perm('{}.delete'.format(prefix)):
actions.append(self.make_action('delete', icon='trash', url=self.default_delete_url))
return actions
def default_edit_url(self, row, i=None):
if self.editable_instance(row):
return self.request.route_url('{}.edit'.format(self.get_route_prefix()),
**self.get_action_route_kwargs(row))
def default_delete_url(self, row, i=None):
if self.deletable_instance(row):
return self.request.route_url('{}.delete'.format(self.get_route_prefix()),
**self.get_action_route_kwargs(row))
def make_action(self, key, url=None, **kwargs):
"""
Make a new :class:`GridAction` instance for the current grid.
"""
if url is None:
route = '{}.{}'.format(self.get_route_prefix(), key)
url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r))
return grids.GridAction(key, url=url, **kwargs)
def get_action_route_kwargs(self, row):
"""
Hopefully generic kwarg generator for basic action routes.
"""
try:
mapper = orm.object_mapper(row)
except orm.exc.UnmappedInstanceError:
return {self.model_key: row[self.model_key]}
else:
keys = [k.key for k in mapper.primary_key]
values = [getattr(row, k) for k in keys]
return dict(zip(keys, values))
def get_data(self, session=None):
"""
Generate the base data set for the grid. This typically will be a
SQLAlchemy query against the view's model class, but subclasses may
override this to support arbitrary data sets.
Note that if your view is typical and uses a SA model, you should not
override this methid, but override :meth:`query()` instead.
"""
if session is None:
session = self.Session()
return self.query(session)
def query(self, session):
"""
Produce the initial/base query for the master grid. By default this is
simply a query against the model class, but you may override as
necessary to apply any sort of pre-filtering etc. This is useful if
say, you don't ever want to show records of a certain type to non-admin
users. You would modify the base query to hide what you wanted,
regardless of the user's filter selections.
"""
return session.query(self.get_model_class())
def get_effective_query(self, session=None, **kwargs):
return self.get_effective_data(session=session, **kwargs)
def checkbox(self, instance):
"""
Returns a boolean indicating whether ot not a checkbox should be
rendererd for the given row. Default implementation returns ``True``
in all cases.
"""
return True
def checked(self, instance):
"""
Returns a boolean indicating whether ot not a checkbox should be
checked by default, for the given row. Default implementation returns
``False`` in all cases.
"""
return False
##############################
# CRUD Stuff
##############################
def get_instance(self):
"""
Fetch the current model instance by inspecting the route kwargs and
doing a database lookup. If the instance cannot be found, raises 404.
"""
# TODO: this can't handle composite model key..is that needed?
key = self.request.matchdict[self.get_model_key()]
instance = self.Session.query(self.get_model_class()).get(key)
if not instance:
raise httpexceptions.HTTPNotFound()
return instance
def get_instance_title(self, instance):
"""
Return a "pretty" title for the instance, to be used in the page title etc.
"""
return six.text_type(instance)
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.
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
fieldset = self.make_fieldset(instance)
self._preconfigure_fieldset(fieldset)
self.configure_fieldset(fieldset)
self._postconfigure_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))
factory = kwargs.pop('factory', forms.AlchemyForm)
kwargs.setdefault('session', self.Session())
form = factory(self.request, fieldset, **kwargs)
form.readonly = self.viewing
return form
def save_form(self, form):
form.save()
def make_fieldset(self, instance, **kwargs):
"""
Make a FormAlchemy fieldset for the given model instance.
"""
kwargs.setdefault('session', self.Session())
kwargs.setdefault('request', self.request)
fieldset = fa.FieldSet(instance, **kwargs)
fieldset.prettify = prettify
return fieldset
def _preconfigure_fieldset(self, fieldset):
pass
def configure_fieldset(self, fieldset):
"""
Configure the given fieldset.
"""
fieldset.configure()
def _postconfigure_fieldset(self, fieldset):
pass
def before_create(self, form):
"""
Event hook, called just after the form to create a new instance has
been validated, but prior to the form itself being saved.
"""
def after_create(self, instance):
"""
Event hook, called just after a new instance is saved.
"""
def editable_instance(self, instance):
"""
Returns boolean indicating whether or not the given instance can be
considered "editable". Returns ``True`` by default; override as
necessary.
"""
return True
def after_edit(self, instance):
"""
Event hook, called just after an existing instance is saved.
"""
def deletable_instance(self, instance):
"""
Returns boolean indicating whether or not the given instance can be
considered "deletable". Returns ``True`` by default; override as
necessary.
"""
return True
def before_delete(self, instance):
"""
Event hook, called just before deletion is attempted.
"""
def delete_instance(self, instance):
"""
Delete the instance, or mark it as deleted, or whatever you need to do.
"""
# Flush immediately to force any pending integrity errors etc.; that
# way we don't set flash message until we know we have success.
self.Session.delete(instance)
self.Session.flush()
def get_after_delete_url(self, instance):
"""
Returns the URL to which the user should be redirected after
successfully "deleting" the given instance.
"""
if hasattr(self, 'after_delete_url'):
if callable(self.after_delete_url):
return self.after_delete_url(instance)
return self.after_delete_url
return self.get_index_url()
##############################
# Associated Rows Stuff
##############################
def create_row(self):
"""
View for creating a new row record.
"""
self.creating = True
parent = self.get_instance()
index_url = self.get_action_url('view', parent)
form = self.make_row_form(self.model_row_class, cancel_url=index_url)
if self.request.method == 'POST':
if form.validate():
self.before_create_row(form)
self.save_create_row_form(form)
obj = form.fieldset.model
self.after_create_row(obj)
return self.redirect_after_create_row(obj)
return self.render_to_response('create_row', {
'index_url': index_url,
'index_title': '{} {}'.format(
self.get_model_title(),
self.get_instance_title(parent)),
'form': form})
def save_create_row_form(self, form):
self.save_row_form(form)
def before_create_row(self, form):
pass
def after_create_row(self, row_object):
pass
def redirect_after_create_row(self, row, mobile=False):
return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
def mobile_create_row(self):
"""
Mobile view for creating a new row object
"""
self.creating = True
parent = self.get_instance()
instance_url = self.get_action_url('view', parent, mobile=True)
form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url)
if self.request.method == 'POST':
if form.validate():
self.before_create_row(form)
# let save() return alternate object if necessary
obj = self.save_create_row_form(form) or form.fieldset.model
self.after_create_row(obj)
return self.redirect_after_create_row(obj, mobile=True)
return self.render_to_response('create_row', {
'instance_title': self.get_instance_title(parent),
'instance_url': instance_url,
'parent_object': parent,
'form': form,
}, mobile=True)
def view_row(self):
"""
View for viewing details of a single data row.
"""
self.viewing = True
row = self.get_row_instance()
form = self.make_row_form(row)
parent = self.get_parent(row)
return self.render_to_response('view_row', {
'instance': row,
'instance_title': self.get_instance_title(parent),
'instance_url': self.get_action_url('view', parent),
'instance_editable': self.row_editable(row),
'instance_deletable': self.row_deletable(row),
'rows_creatable': self.rows_creatable and self.rows_creatable_for(parent),
'model_title': self.get_row_model_title(),
'model_title_plural': self.get_row_model_title_plural(),
'parent_model_title': self.get_model_title(),
'action_url': self.get_row_action_url,
'form': form})
def rows_creatable_for(self, instance):
"""
Returns boolean indicating whether or not the given instance should
allow new rows to be added to it.
"""
return True
def row_editable(self, row):
"""
Returns boolean indicating whether or not the given row can be
considered "editable". Returns ``True`` by default; override as
necessary.
"""
return True
def edit_row(self):
"""
View for editing an existing model record.
"""
self.editing = True
row = self.get_row_instance()
form = self.make_row_form(row)
if self.request.method == 'POST':
if form.validate():
self.save_edit_row_form(form)
return self.redirect_after_edit_row(row)
parent = self.get_parent(row)
return self.render_to_response('edit_row', {
'instance': row,
'row_parent': parent,
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent),
'parent_instance': parent,
'instance_title': self.get_row_instance_title(row),
'instance_deletable': self.row_deletable(row),
'form': form})
def mobile_edit_row(self):
"""
Mobile view for editing a row object
"""
self.editing = True
row = self.get_row_instance()
instance_url = self.get_row_action_url('view', row, mobile=True)
form = self.make_mobile_row_form(row)
if self.request.method == 'POST':
if form.validate():
self.save_edit_row_form(form)
return self.redirect_after_edit_row(row, mobile=True)
parent = self.get_parent(row)
return self.render_to_response('edit_row', {
'instance': row,
'instance_title': self.get_row_instance_title(row),
'instance_url': instance_url,
'instance_deletable': self.row_deletable(row),
'parent_instance': parent,
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent, mobile=True),
'form': form},
mobile=True)
def save_edit_row_form(self, form):
self.save_row_form(form)
self.after_edit_row(form.fieldset.model)
def save_row_form(self, form):
form.save()
def after_edit_row(self, row):
"""
Event hook, called just after an existing row object is saved.
"""
def redirect_after_edit_row(self, row, mobile=False):
return self.redirect(self.get_row_action_url('view', row, mobile=True))
def row_deletable(self, row):
"""
Returns boolean indicating whether or not the given row can be
considered "deletable". Returns ``True`` by default; override as
necessary.
"""
return True
def delete_row(self):
"""
"Delete" a sub-row from the parent.
"""
row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid'])
if not row:
raise httpexceptions.HTTPNotFound()
self.Session.delete(row)
return self.redirect(self.get_action_url('edit', self.get_parent(row)))
def get_parent(self, row):
raise NotImplementedError
def get_row_instance_title(self, instance):
return self.get_row_model_title()
def get_row_instance(self):
key = self.request.matchdict[self.get_model_key()]
instance = self.Session.query(self.model_row_class).get(key)
if not instance:
raise httpexceptions.HTTPNotFound()
return instance
def make_row_form(self, instance, **kwargs):
"""
Make a FormAlchemy form for use with CRUD views for a data *row*.
"""
# TODO: Some hacky stuff here, to accommodate old form cruft. Probably
# should refactor forms soon too, but trying to avoid it for the moment.
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
fieldset = self.make_fieldset(instance)
self._preconfigure_row_fieldset(fieldset)
self.configure_row_fieldset(fieldset)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if 'cancel_url' not in kwargs:
if self.creating:
kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(instance))
else:
kwargs['cancel_url'] = self.get_row_action_url('view', instance)
kwargs.setdefault('session', self.Session())
form = forms.AlchemyForm(self.request, fieldset, **kwargs)
form.readonly = self.viewing
return form
def _preconfigure_row_fieldset(self, fs):
pass
def configure_row_fieldset(self, fs):
fs.configure()
def get_row_action_url(self, action, row, mobile=False):
"""
Generate a URL for the given action on the given row.
"""
route_name = '{}.{}'.format(self.get_row_route_prefix(), action)
if mobile:
route_name = 'mobile.{}'.format(route_name)
return self.request.route_url(route_name, **self.get_row_action_route_kwargs(row))
def get_row_action_route_kwargs(self, row):
"""
Hopefully generic kwarg generator for basic action routes.
"""
# TODO: make this smarter?
return {'uuid': row.uuid}
##############################
# Config Stuff
##############################
@classmethod
def defaults(cls, config):
"""
Provide default configuration for a master view.
"""
cls._defaults(config)
@classmethod
def _defaults(cls, config):
"""
Provide default configuration for a master view.
"""
rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
model_key = cls.get_model_key()
model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural()
if cls.has_rows:
row_route_prefix = cls.get_row_route_prefix()
row_url_prefix = cls.get_row_url_prefix()
row_model_title = cls.get_row_model_title()
config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
# list/search
if cls.listable:
config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix),
"List / search {}".format(model_title_plural))
config.add_route(route_prefix, '{}/'.format(url_prefix))
config.add_view(cls, attr='index', route_name=route_prefix,
permission='{}.list'.format(permission_prefix))
if cls.supports_mobile:
config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix))
config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix),
permission='{}.list'.format(permission_prefix))
# create
if cls.creatable or cls.mobile_creatable:
config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix),
"Create new {}".format(model_title))
if cls.creatable:
config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix))
config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
if cls.mobile_creatable:
config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix))
config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# bulk delete
if cls.bulk_deletable:
config.add_route('{}.bulk_delete'.format(route_prefix), '{}/bulk-delete'.format(url_prefix))
config.add_view(cls, attr='bulk_delete', route_name='{}.bulk_delete'.format(route_prefix),
permission='{}.bulk_delete'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.bulk_delete'.format(permission_prefix),
"Bulk delete {}".format(model_title_plural))
# merge
if cls.mergeable:
config.add_route('{}.merge'.format(route_prefix), '{}/merge'.format(url_prefix))
config.add_view(cls, attr='merge', route_name='{}.merge'.format(route_prefix),
permission='{}.merge'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix),
"Merge 2 {}".format(model_title_plural))
# view
if cls.viewable:
config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix),
"View details for {}".format(model_title))
if cls.has_pk_fields:
config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix),
"View all PK-type fields for {}".format(model_title_plural))
# view by grid index
config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix))
config.add_view(cls, attr='view_index', route_name='{}.view_index'.format(route_prefix),
permission='{}.view'.format(permission_prefix))
# view by record key
config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
permission='{}.view'.format(permission_prefix))
if cls.supports_mobile:
config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}/{{{}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
permission='{}.view'.format(permission_prefix))
# version history
if cls.has_versions and rattail_config and rattail_config.versioning_enabled():
config.add_tailbone_permission(permission_prefix, '{}.versions'.format(permission_prefix),
"View version history for {}".format(model_title))
config.add_route('{}.versions'.format(route_prefix), '{}/{{{}}}/versions/'.format(url_prefix, model_key))
config.add_view(cls, attr='versions', route_name='{}.versions'.format(route_prefix),
permission='{}.versions'.format(permission_prefix))
config.add_route('{}.version'.format(route_prefix), '{}/{{{}}}/versions/{{txnid}}'.format(url_prefix, model_key))
config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix),
permission='{}.versions'.format(permission_prefix))
# download
if cls.downloadable:
config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key))
config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix),
permission='{}.download'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix),
"Download associated data for {}".format(model_title))
# edit
if cls.editable:
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))
config.add_tailbone_permission(permission_prefix, '{0}.edit'.format(permission_prefix),
"Edit {0}".format(model_title))
# delete
if cls.deletable:
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))
config.add_tailbone_permission(permission_prefix, '{0}.delete'.format(permission_prefix),
"Delete {0}".format(model_title))
### sub-rows stuff follows
# create row
if cls.has_rows:
if cls.rows_creatable or cls.mobile_rows_creatable:
config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix),
"Create new {} rows".format(model_title))
if cls.rows_creatable:
config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key))
config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix),
permission='{}.create_row'.format(permission_prefix))
if cls.mobile_rows_creatable:
config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/{{{}}}/new-row'.format(url_prefix, model_key))
config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix),
permission='{}.create_row'.format(permission_prefix))
# view row
if cls.has_rows:
if cls.rows_viewable:
config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix))
config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix),
permission='{}.view'.format(permission_prefix))
if cls.mobile_rows_viewable:
config.add_route('mobile.{}.view'.format(row_route_prefix), '/mobile{}/{{uuid}}'.format(row_url_prefix))
config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view'.format(row_route_prefix),
permission='{}.view'.format(permission_prefix))
# edit row
if cls.has_rows:
if cls.rows_editable or cls.mobile_rows_editable:
config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix),
"Edit individual {} rows".format(model_title))
if cls.rows_editable:
config.add_route('{}.edit'.format(row_route_prefix), '{}/{{uuid}}/edit'.format(row_url_prefix))
config.add_view(cls, attr='edit_row', route_name='{}.edit'.format(row_route_prefix),
permission='{}.edit_row'.format(permission_prefix))
if cls.mobile_rows_editable:
config.add_route('mobile.{}.edit'.format(row_route_prefix), '/mobile{}/{{uuid}}/edit'.format(row_url_prefix))
config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit'.format(row_route_prefix),
permission='{}.edit_row'.format(permission_prefix))
# delete row
if cls.has_rows and cls.rows_deletable:
config.add_route('{}.delete'.format(row_route_prefix), '{}/{{uuid}}/delete'.format(row_url_prefix))
config.add_view(cls, attr='delete_row', route_name='{}.delete'.format(row_route_prefix),
permission='{}.delete_row'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
"Delete individual {} rows".format(model_title))