diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 56b4faae..5bb155bf 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -15,7 +15,34 @@
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
%def>
@@ -26,6 +53,13 @@
% endif
%def>
-<%def name="grid_tools()">%def>
+<%def name="grid_tools()">
+ % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)):
+ ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')}
+ ${h.hidden('uuids')}
+ Merge 2 ${model_title_plural}
+ ${h.end_form()}
+ % endif
+%def>
${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako
new file mode 100644
index 00000000..6e1abac5
--- /dev/null
+++ b/tailbone/templates/master/merge.mako
@@ -0,0 +1,135 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/base.mako" />
+
+<%def name="title()">Merge 2 ${model_title_plural}%def>
+
+<%def name="head_tags()">
+ ${parent.head_tags()}
+
+
+%def>
+
+<%def name="context_menu_items()">
+
${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}
+%def>
+
+
+
+
+ You are about to merge two ${model_title} records,
+ (possibly) along with various related data. The tool you are using now
+ is somewhat generic and is not able to give you the full picture of the
+ implications of this merge. You are urged to proceed with caution!
+
+
+
+ The ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')}
+ will be kept
+ and the ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')}
+ will be deleted . The one which is to be kept may also
+ be updated to reflect certain aspects of the one being deleted; however again
+ the details are up to the app logic for this type of merge and aren't fully
+ known to the generic tool which you're using now.
+
+
+
+
+
+ field name
+ deleting ${model_title}
+ keeping ${model_title}
+ resulting ${model_title}
+
+
+
+ % for field in sorted(merge_fields):
+
+ ${field}
+ ${repr(remove_data[field])}
+ ${repr(keep_data[field])}
+ ${repr(resulting_data[field])}
+
+ % endfor
+
+
+
+${h.form(request.current_route_url(), class_='merge')}
+
+${h.end_form()}
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index a6490129..e9be3536 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -55,6 +55,7 @@ class MasterView(View):
viewable = True
editable = True
deletable = True
+ mergeable = False
listing = False
creating = False
@@ -394,6 +395,70 @@ class MasterView(View):
'instance_title': instance_title,
'form': form})
+ def get_merge_fields(self):
+ if hasattr(self, 'merge_fields'):
+ return self.merge_fields
+
+ def get_merge_coalesce_fields(self):
+ if hasattr(self, 'merge_coalesce_fields'):
+ return self.merge_coalesce_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 = unicode(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 keep/remove 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]
+ return result
+
+ def merge_objects(self, removing, keeping):
+ raise NotImplementedError("please implement `{}.merge_objects()`".format(self.__class__.__name__))
+
##############################
# Core Stuff
##############################
@@ -635,7 +700,8 @@ class MasterView(View):
'default_sortkey': getattr(self, 'default_sortkey', None),
'sortdir': getattr(self, 'sortdir', 'asc'),
'pageable': self.pageable,
- 'checkboxes': self.checkboxes,
+ 'checkboxes': self.checkboxes or (
+ self.mergeable and self.request.has_perm('{}.merge'.format(self.get_permission_prefix()))),
'checked': self.checked,
'row_attrs': self.get_row_attrs,
'cell_attrs': self.get_cell_attrs,
@@ -662,8 +728,12 @@ class MasterView(View):
defined; it depends on the type of data the grid deals with.
"""
if callable(self.row_attrs):
- return self.row_attrs(row, i)
- return 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):
"""
@@ -1142,6 +1212,14 @@ class MasterView(View):
config.add_tailbone_permission(permission_prefix, '{0}.create'.format(permission_prefix),
"Create new {0}".format(model_title))
+ # 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),