diff --git a/tailbone/forms/fields.py b/tailbone/forms/fields.py
index 53651369..8e8b9e8b 100644
--- a/tailbone/forms/fields.py
+++ b/tailbone/forms/fields.py
@@ -42,9 +42,8 @@ def AssociationProxyField(name, **kwargs):
setattr(self.parent.model, self.name,
self.renderer.deserialize())
- def value(model):
- from rattail.db import model
- return getattr(model, name, None)
+ def value(obj):
+ return getattr(obj, name, None)
kwargs.setdefault('value', value)
return ProxyField(name, **kwargs)
diff --git a/tailbone/static/css/theme-better.css b/tailbone/static/css/theme-better.css
index 923cc613..ac4f6f2d 100644
--- a/tailbone/static/css/theme-better.css
+++ b/tailbone/static/css/theme-better.css
@@ -75,8 +75,12 @@ header .global .feedback {
margin-right: 1em;
}
-header .page h1 {
+header .page {
border-bottom: 1px solid lightgrey;
+ padding: 0.5em;
+}
+
+header .page h1 {
margin: 0;
padding: 0 0 0 0.5em;
}
diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako
new file mode 100644
index 00000000..9bd03c29
--- /dev/null
+++ b/tailbone/templates/master/versions.mako
@@ -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'))}
+
+%def>
+
+${grid.render_complete()|n}
diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako
new file mode 100644
index 00000000..a75704a5
--- /dev/null
+++ b/tailbone/templates/master/view_version.mako
@@ -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()}
+
+%def>
+
+<%def name="content_title()">
+
+ % 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
+
+ ${self.title()}
+%def>
+
+
+
+% for version in versions:
+
+ ${version.version_parent.get_model_title()}
+
+ % if version.previous:
+
+
+
+ field name |
+ old value |
+ new value |
+
+
+
+ % for field in fields_for_version(version):
+
+ ${field} |
+ ${repr(getattr(version.previous, field))} |
+ ${repr(getattr(version, field))} |
+
+ % endfor
+
+
+ % else:
+
+
+
+ field name |
+ old value |
+ new value |
+
+
+
+ % for field in fields_for_version(version):
+
+ ${field} |
+ |
+ ${repr(getattr(version, field))} |
+
+ % endfor
+
+
+ % endif
+
+% endfor
diff --git a/tailbone/templates/themes/better/master/view.mako b/tailbone/templates/themes/better/master/view.mako
index fb98b980..7f51d2d1 100644
--- a/tailbone/templates/themes/better/master/view.mako
+++ b/tailbone/templates/themes/better/master/view.mako
@@ -1,4 +1,4 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
<%inherit file="tailbone:templates/master/view.mako" />
<%def name="content_title()">
@@ -7,6 +7,9 @@
<%def name="context_menu_items()">
${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
+ % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
+ ${h.link_to("Version History", action_url('versions', instance))}
+ % endif
% if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
% endif
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index dd4fbc53..1327262f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -30,7 +30,11 @@ 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
@@ -75,6 +79,8 @@ class MasterView(View):
grid_index = None
use_index_links = False
+ has_versions = False
+
# ROW-RELATED ATTRS FOLLOW:
has_rows = False
@@ -323,6 +329,161 @@ class MasterView(View):
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,
+ '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):
"""
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))
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):
"""
@@ -1562,6 +1730,7 @@ class MasterView(View):
"""
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()
@@ -1638,6 +1807,17 @@ class MasterView(View):
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.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))
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index e4be5118..1d55d422 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -61,8 +61,9 @@ class PeopleView(MasterView):
model_class = model.Person
model_title_plural = "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_(
model.PersonEmailAddress.parent_uuid == model.Person.uuid,
model.PersonEmailAddress.preference == 1))
@@ -89,6 +90,7 @@ class PeopleView(MasterView):
g.default_sortkey = 'display_name'
+ def configure_grid(self, g):
g.configure(
include=[
g.display_name.label("Full Name"),