From 0b68d56ddb3fada8ffe1ba23def22ce00e891da6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 5 Jul 2017 03:07:35 -0500 Subject: [PATCH] Add basic versioning history support for master view as with actual data versioning, we only support Person thus far --- tailbone/forms/fields.py | 5 +- tailbone/static/css/theme-better.css | 6 +- tailbone/templates/master/versions.mako | 21 ++ tailbone/templates/master/view_version.mako | 139 ++++++++++++++ .../templates/themes/better/master/view.mako | 5 +- tailbone/views/master.py | 180 ++++++++++++++++++ tailbone/views/people.py | 4 +- 7 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 tailbone/templates/master/versions.mako create mode 100644 tailbone/templates/master/view_version.mako 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 name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} + + + +${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} + +## TODO: this was basically copied from Revel diff template..need to abstract + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%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()}

+ + +
+ +
+ +
+ +
${h.pretty_datetime(request.rattail_config, changed)}
+
+ +
+ +
${transaction.user}
+
+ +
+ +
${transaction.remote_addr}
+
+ +
+ +
${transaction.meta.get('comment') or ''}
+
+ +
+ +
+ +% for version in versions: + +

${version.version_parent.get_model_title()}

+ + % if version.previous: + + + + + + + + + + % for field in fields_for_version(version): + + + + + + % endfor + +
field nameold valuenew value
${field}${repr(getattr(version.previous, field))}${repr(getattr(version, field))}
+ % else: + + + + + + + + + + % for field in fields_for_version(version): + + + + + + % endfor + +
field nameold valuenew value
${field} ${repr(getattr(version, field))}
+ % 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"),