Add basic versioning history support for master view
as with actual data versioning, we only support Person thus far
This commit is contained in:
parent
7340ef1f9b
commit
0b68d56ddb
|
@ -42,9 +42,8 @@ def AssociationProxyField(name, **kwargs):
|
||||||
setattr(self.parent.model, self.name,
|
setattr(self.parent.model, self.name,
|
||||||
self.renderer.deserialize())
|
self.renderer.deserialize())
|
||||||
|
|
||||||
def value(model):
|
def value(obj):
|
||||||
from rattail.db import model
|
return getattr(obj, name, None)
|
||||||
return getattr(model, name, None)
|
|
||||||
|
|
||||||
kwargs.setdefault('value', value)
|
kwargs.setdefault('value', value)
|
||||||
return ProxyField(name, **kwargs)
|
return ProxyField(name, **kwargs)
|
||||||
|
|
|
@ -75,8 +75,12 @@ header .global .feedback {
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .page h1 {
|
header .page {
|
||||||
border-bottom: 1px solid lightgrey;
|
border-bottom: 1px solid lightgrey;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .page h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 0.5em;
|
padding: 0 0 0 0.5em;
|
||||||
}
|
}
|
||||||
|
|
21
tailbone/templates/master/versions.mako
Normal file
21
tailbone/templates/master/versions.mako
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
## ##############################################################################
|
||||||
|
##
|
||||||
|
## Default master 'versions' template, for showing an object's version history.
|
||||||
|
##
|
||||||
|
## ##############################################################################
|
||||||
|
<%inherit file="/base.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">${model_title_plural}: ${instance_title} (History)</%def>
|
||||||
|
|
||||||
|
<%def name="extra_javascript()">
|
||||||
|
${parent.extra_javascript()}
|
||||||
|
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(function() {
|
||||||
|
$('.newgrid-wrapper').gridwrapper();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
${grid.render_complete()|n}
|
139
tailbone/templates/master/view_version.mako
Normal file
139
tailbone/templates/master/view_version.mako
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/base.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">${instance_title} @ ver ${transaction.id}</%def>
|
||||||
|
|
||||||
|
## TODO: this was basically copied from Revel diff template..need to abstract
|
||||||
|
|
||||||
|
<%def name="extra_styles()">
|
||||||
|
${parent.extra_styles()}
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-left: 1px solid black;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
font-size: 11pt;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-left: 50px;
|
||||||
|
min-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th,
|
||||||
|
table td {
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
border-right: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td.value {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
table.new td.host-value,
|
||||||
|
table.diff tr.diff td.host-value {
|
||||||
|
background-color: #cfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.deleted td.local-value,
|
||||||
|
table.diff tr.diff td.local-value {
|
||||||
|
background-color: #fcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="content_title()">
|
||||||
|
<div style="float: right;">
|
||||||
|
% if previous_transaction:
|
||||||
|
${h.link_to(u"« Older", url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=previous_transaction.id), class_='button')}
|
||||||
|
% else:
|
||||||
|
${h.link_to(u"« Older", '#', class_='button', disabled='disabled')}
|
||||||
|
% endif
|
||||||
|
% if next_transaction:
|
||||||
|
${h.link_to(u"Newer »", url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=next_transaction.id), class_='button')}
|
||||||
|
% else:
|
||||||
|
${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')}
|
||||||
|
% endif
|
||||||
|
</div>
|
||||||
|
<h1>${self.title()}</h1>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<div class="form-wrapper">
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
|
||||||
|
<div class="field-wrapper">
|
||||||
|
<label>Changed</label>
|
||||||
|
<div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-wrapper">
|
||||||
|
<label>Changed by</label>
|
||||||
|
<div class="field">${transaction.user}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-wrapper">
|
||||||
|
<label>IP Address</label>
|
||||||
|
<div class="field">${transaction.remote_addr}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-wrapper">
|
||||||
|
<label>Comment</label>
|
||||||
|
<div class="field">${transaction.meta.get('comment') or ''}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- form-wrapper -->
|
||||||
|
|
||||||
|
% for version in versions:
|
||||||
|
|
||||||
|
<h2>${version.version_parent.get_model_title()}</h2>
|
||||||
|
|
||||||
|
% if version.previous:
|
||||||
|
<table class="diff">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>field name</th>
|
||||||
|
<th>old value</th>
|
||||||
|
<th>new value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
% for field in fields_for_version(version):
|
||||||
|
<tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}>
|
||||||
|
<td class="field">${field}</td>
|
||||||
|
<td class="value local-value">${repr(getattr(version.previous, field))}</td>
|
||||||
|
<td class="value host-value">${repr(getattr(version, field))}</td>
|
||||||
|
</tr>
|
||||||
|
% endfor
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
% else:
|
||||||
|
<table class="new">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>field name</th>
|
||||||
|
<th>old value</th>
|
||||||
|
<th>new value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
% for field in fields_for_version(version):
|
||||||
|
<tr>
|
||||||
|
<td class="field">${field}</td>
|
||||||
|
<td class="value local-value"> </td>
|
||||||
|
<td class="value host-value">${repr(getattr(version, field))}</td>
|
||||||
|
</tr>
|
||||||
|
% endfor
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
% endfor
|
|
@ -1,4 +1,4 @@
|
||||||
## -*- coding: utf-8 -*-
|
## -*- coding: utf-8; -*-
|
||||||
<%inherit file="tailbone:templates/master/view.mako" />
|
<%inherit file="tailbone:templates/master/view.mako" />
|
||||||
|
|
||||||
<%def name="content_title()">
|
<%def name="content_title()">
|
||||||
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
<%def name="context_menu_items()">
|
<%def name="context_menu_items()">
|
||||||
<li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
|
<li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
|
||||||
|
% if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
|
||||||
|
<li>${h.link_to("Version History", action_url('versions', instance))}</li>
|
||||||
|
% endif
|
||||||
% if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
|
% if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
|
||||||
<li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
|
<li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
|
||||||
% endif
|
% endif
|
||||||
|
|
|
@ -30,7 +30,11 @@ import six
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
import sqlalchemy_continuum as continuum
|
||||||
|
|
||||||
|
from rattail.db.continuum import model_transaction_query
|
||||||
from rattail.util import prettify
|
from rattail.util import prettify
|
||||||
|
from rattail.time import localtime
|
||||||
|
|
||||||
import formalchemy as fa
|
import formalchemy as fa
|
||||||
from pyramid import httpexceptions
|
from pyramid import httpexceptions
|
||||||
|
@ -75,6 +79,8 @@ class MasterView(View):
|
||||||
grid_index = None
|
grid_index = None
|
||||||
use_index_links = False
|
use_index_links = False
|
||||||
|
|
||||||
|
has_versions = False
|
||||||
|
|
||||||
# ROW-RELATED ATTRS FOLLOW:
|
# ROW-RELATED ATTRS FOLLOW:
|
||||||
|
|
||||||
has_rows = False
|
has_rows = False
|
||||||
|
@ -323,6 +329,161 @@ class MasterView(View):
|
||||||
tools=self.make_row_grid_tools(instance))
|
tools=self.make_row_grid_tools(instance))
|
||||||
return self.render_to_response('view', context)
|
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,
|
||||||
|
'index_title': "{}: {}".format(self.get_model_title_plural(), instance_title),
|
||||||
|
'index_url': self.get_action_url('view', instance),
|
||||||
|
'grid': grid,
|
||||||
|
})
|
||||||
|
|
||||||
|
def make_version_grid(self, instance=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Make and return a new (configured) version grid instance.
|
||||||
|
"""
|
||||||
|
if instance is None:
|
||||||
|
instance = self.get_instance()
|
||||||
|
factory = self.get_version_grid_factory()
|
||||||
|
key = self.get_version_grid_key()
|
||||||
|
data = self.get_version_data(instance)
|
||||||
|
kwargs = self.make_version_grid_kwargs(**kwargs)
|
||||||
|
kwargs['model_class'] = continuum.transaction_class(self.get_model_class())
|
||||||
|
grid = factory(key, self.request, data=data, **kwargs)
|
||||||
|
self.preconfigure_version_grid(grid)
|
||||||
|
self.configure_version_grid(grid)
|
||||||
|
grid.load_settings()
|
||||||
|
return grid
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_version_grid_factory(cls):
|
||||||
|
"""
|
||||||
|
Returns the grid factory or class which is to be used when creating new
|
||||||
|
version grid instances.
|
||||||
|
"""
|
||||||
|
return getattr(cls, 'version_grid_factory', AlchemyGrid)
|
||||||
|
|
||||||
|
@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.uuid, model_class)
|
||||||
|
return query.order_by(transaction_class.issued_at.desc())
|
||||||
|
|
||||||
|
def make_version_grid_kwargs(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a dictionary of kwargs to be passed to the factory when
|
||||||
|
constructing a new version grid.
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
'width': 'full',
|
||||||
|
'pageable': True,
|
||||||
|
}
|
||||||
|
if 'main_actions' not in kwargs:
|
||||||
|
route = '{}.version'.format(self.get_route_prefix())
|
||||||
|
instance = kwargs.get('instance') or self.get_instance()
|
||||||
|
url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
|
||||||
|
defaults['main_actions'] = [
|
||||||
|
self.make_action('view', icon='zoomin', url=url),
|
||||||
|
]
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def preconfigure_version_grid(self, g):
|
||||||
|
g.issued_at.set(label="Changed")
|
||||||
|
g.user.set(label="Changed by")
|
||||||
|
g.remote_addr.set(label="IP Address")
|
||||||
|
g.append(fa.Field('comment', value=lambda txn: txn.meta.get('comment')))
|
||||||
|
g.default_sortkey = 'issued_at'
|
||||||
|
g.default_sortdir = 'desc'
|
||||||
|
|
||||||
|
def configure_version_grid(self, g):
|
||||||
|
"""
|
||||||
|
Configure the version grid, customizing as necessary.
|
||||||
|
"""
|
||||||
|
g.configure(include=[
|
||||||
|
g.issued_at,
|
||||||
|
g.user,
|
||||||
|
g.remote_addr,
|
||||||
|
g.comment,
|
||||||
|
], readonly=True)
|
||||||
|
|
||||||
|
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.uuid, model_class)
|
||||||
|
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': instance_title,
|
||||||
|
'index_title': "{}: {} (History)".format(self.get_model_title_plural(), instance_title),
|
||||||
|
'index_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,
|
||||||
|
'fields_for_version': self.fields_for_version,
|
||||||
|
})
|
||||||
|
|
||||||
|
def fields_for_version(self, version):
|
||||||
|
mapper = orm.class_mapper(version.__class__)
|
||||||
|
fields = sorted(mapper.columns.keys())
|
||||||
|
fields.remove('uuid')
|
||||||
|
fields.remove('transaction_id')
|
||||||
|
fields.remove('end_transaction_id')
|
||||||
|
fields.remove('operation_type')
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def get_relevant_versions(self, transaction, instance):
|
||||||
|
version_class = self.get_model_version_class()
|
||||||
|
query = self.Session.query(version_class)\
|
||||||
|
.filter(version_class.transaction == transaction)\
|
||||||
|
.filter(version_class.uuid == instance.uuid)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
def mobile_view(self):
|
def mobile_view(self):
|
||||||
"""
|
"""
|
||||||
Mobile view for displaying a single object's details
|
Mobile view for displaying a single object's details
|
||||||
|
@ -783,6 +944,13 @@ class MasterView(View):
|
||||||
raise NotImplementedError("You must define the `model_class` for: {}".format(cls))
|
raise NotImplementedError("You must define the `model_class` for: {}".format(cls))
|
||||||
return getattr(cls, 'model_class', None)
|
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
|
@classmethod
|
||||||
def get_normalized_model_name(cls):
|
def get_normalized_model_name(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -1562,6 +1730,7 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
Provide default configuration for a master view.
|
Provide default configuration for a master view.
|
||||||
"""
|
"""
|
||||||
|
rattail_config = config.registry.settings.get('rattail_config')
|
||||||
route_prefix = cls.get_route_prefix()
|
route_prefix = cls.get_route_prefix()
|
||||||
url_prefix = cls.get_url_prefix()
|
url_prefix = cls.get_url_prefix()
|
||||||
permission_prefix = cls.get_permission_prefix()
|
permission_prefix = cls.get_permission_prefix()
|
||||||
|
@ -1638,6 +1807,17 @@ class MasterView(View):
|
||||||
config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
|
config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
|
||||||
permission='{}.view'.format(permission_prefix))
|
permission='{}.view'.format(permission_prefix))
|
||||||
|
|
||||||
|
# version history
|
||||||
|
if cls.has_versions 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
|
# download
|
||||||
if cls.downloadable:
|
if cls.downloadable:
|
||||||
config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key))
|
config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key))
|
||||||
|
|
|
@ -61,8 +61,9 @@ class PeopleView(MasterView):
|
||||||
model_class = model.Person
|
model_class = model.Person
|
||||||
model_title_plural = "People"
|
model_title_plural = "People"
|
||||||
route_prefix = 'people'
|
route_prefix = 'people'
|
||||||
|
has_versions = True
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def _preconfigure_grid(self, g):
|
||||||
g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_(
|
g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_(
|
||||||
model.PersonEmailAddress.parent_uuid == model.Person.uuid,
|
model.PersonEmailAddress.parent_uuid == model.Person.uuid,
|
||||||
model.PersonEmailAddress.preference == 1))
|
model.PersonEmailAddress.preference == 1))
|
||||||
|
@ -89,6 +90,7 @@ class PeopleView(MasterView):
|
||||||
|
|
||||||
g.default_sortkey = 'display_name'
|
g.default_sortkey = 'display_name'
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
g.configure(
|
g.configure(
|
||||||
include=[
|
include=[
|
||||||
g.display_name.label("Full Name"),
|
g.display_name.label("Full Name"),
|
||||||
|
|
Loading…
Reference in a new issue