[web] Exposes the undo feature to user through a undo-history view (closes #893940)
authorAnthony Truchet <anthony.truchet@logilab.fr>
Mon, 27 Feb 2012 10:03:31 +0100
changeset 8267 486386d9f836
parent 8266 704dda5c07f7
child 8268 c9babe49c1c1
[web] Exposes the undo feature to user through a undo-history view (closes #893940)
i18n/de.po
i18n/en.po
i18n/es.po
i18n/fr.po
web/application.py
web/test/unittest_views_basecontrollers.py
web/views/actions.py
web/views/basecontrollers.py
web/views/undohistory.py
--- a/i18n/de.po	Mon Feb 27 09:45:48 2012 +0100
+++ b/i18n/de.po	Mon Feb 27 10:03:31 2012 +0100
@@ -145,6 +145,10 @@
 msgid "(UNEXISTANT EID)"
 msgstr "(EID nicht gefunden)"
 
+#, python-format
+msgid "(suppressed) entity #%d"
+msgstr ""
+
 msgid "**"
 msgstr "0..n 0..n"
 
@@ -218,6 +222,13 @@
 msgid "About this site"
 msgstr "Über diese Seite"
 
+msgid "Action"
+msgstr ""
+
+#, python-format
+msgid "Added relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
 msgid "Any"
 msgstr "irgendein"
 
@@ -415,6 +426,10 @@
 msgid "Click to sort on this column"
 msgstr ""
 
+#, python-format
+msgid "Created %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "DEBUG"
 msgstr ""
 
@@ -440,6 +455,14 @@
 msgid "Decimal_plural"
 msgstr "Dezimalzahlen"
 
+#, python-format
+msgid "Delete relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
+#, python-format
+msgid "Deleted %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Detected problems"
 msgstr ""
 
@@ -751,6 +774,9 @@
 msgid "TZTime_plural"
 msgstr ""
 
+msgid "Target"
+msgstr ""
+
 #, python-format
 msgid "The view %s can not be applied to this query"
 msgstr "Die Ansicht %s ist auf diese Anfrage nicht anwendbar."
@@ -864,15 +890,29 @@
 msgid "URLs from which content will be imported. You can put one url per line"
 msgstr ""
 
+msgid "Undoable actions"
+msgstr ""
+
+msgid "Undoing"
+msgstr ""
+
 msgid "UniqueConstraint"
 msgstr "eindeutige Einschränkung"
 
 msgid "Unreachable objects"
 msgstr "unzugängliche Objekte"
 
+#, python-format
+msgid "Updated %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Used by:"
 msgstr "benutzt von:"
 
+#, python-format
+msgid "User %(user_eid)s on %(dt)s [%(undo_link)s] \n"
+msgstr ""
+
 msgid "Users and groups management"
 msgstr ""
 
@@ -2044,6 +2084,9 @@
 msgid "default"
 msgstr "Standardwert"
 
+msgid "default /base view for (transaction) actions "
+msgstr ""
+
 msgid "default text format for rich text fields."
 msgstr "Standardformat für Textfelder"
 
--- a/i18n/en.po	Mon Feb 27 09:45:48 2012 +0100
+++ b/i18n/en.po	Mon Feb 27 10:03:31 2012 +0100
@@ -137,6 +137,10 @@
 msgid "(UNEXISTANT EID)"
 msgstr ""
 
+#, python-format
+msgid "(suppressed) entity #%d"
+msgstr ""
+
 msgid "**"
 msgstr "0..n 0..n"
 
@@ -207,6 +211,13 @@
 msgid "About this site"
 msgstr ""
 
+msgid "Action"
+msgstr ""
+
+#, python-format
+msgid "Added relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
 msgid "Any"
 msgstr ""
 
@@ -393,6 +404,10 @@
 msgid "Click to sort on this column"
 msgstr ""
 
+#, python-format
+msgid "Created %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "DEBUG"
 msgstr ""
 
@@ -418,6 +433,14 @@
 msgid "Decimal_plural"
 msgstr "Decimal numbers"
 
+#, python-format
+msgid "Delete relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
+#, python-format
+msgid "Deleted %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Detected problems"
 msgstr ""
 
@@ -727,6 +750,9 @@
 msgid "TZTime_plural"
 msgstr "International times"
 
+msgid "Target"
+msgstr ""
+
 #, python-format
 msgid "The view %s can not be applied to this query"
 msgstr ""
@@ -840,15 +866,29 @@
 msgid "URLs from which content will be imported. You can put one url per line"
 msgstr ""
 
+msgid "Undoable actions"
+msgstr ""
+
+msgid "Undoing"
+msgstr ""
+
 msgid "UniqueConstraint"
 msgstr "unique constraint"
 
 msgid "Unreachable objects"
 msgstr ""
 
+#, python-format
+msgid "Updated %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Used by:"
 msgstr ""
 
+#, python-format
+msgid "User %(user_eid)s on %(dt)s [%(undo_link)s] \n"
+msgstr ""
+
 msgid "Users and groups management"
 msgstr ""
 
@@ -2001,6 +2041,9 @@
 msgid "default"
 msgstr ""
 
+msgid "default /base view for (transaction) actions "
+msgstr ""
+
 msgid "default text format for rich text fields."
 msgstr ""
 
--- a/i18n/es.po	Mon Feb 27 09:45:48 2012 +0100
+++ b/i18n/es.po	Mon Feb 27 10:03:31 2012 +0100
@@ -146,6 +146,10 @@
 msgid "(UNEXISTANT EID)"
 msgstr "(EID INEXISTENTE"
 
+#, python-format
+msgid "(suppressed) entity #%d"
+msgstr ""
+
 msgid "**"
 msgstr "0..n 0..n"
 
@@ -219,6 +223,13 @@
 msgid "About this site"
 msgstr "Información del Sistema"
 
+msgid "Action"
+msgstr ""
+
+#, python-format
+msgid "Added relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
 msgid "Any"
 msgstr "Cualquiera"
 
@@ -415,6 +426,10 @@
 msgid "Click to sort on this column"
 msgstr ""
 
+#, python-format
+msgid "Created %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "DEBUG"
 msgstr ""
 
@@ -440,6 +455,14 @@
 msgid "Decimal_plural"
 msgstr "Decimales"
 
+#, python-format
+msgid "Delete relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr ""
+
+#, python-format
+msgid "Deleted %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Detected problems"
 msgstr "Problemas detectados"
 
@@ -752,6 +775,9 @@
 msgid "TZTime_plural"
 msgstr "Horas internacionales"
 
+msgid "Target"
+msgstr ""
+
 #, python-format
 msgid "The view %s can not be applied to this query"
 msgstr "La vista %s no puede ser aplicada a esta búsqueda"
@@ -867,15 +893,29 @@
 "URLs desde el cual el contenido sera importado. Usted puede incluir un URL "
 "por línea."
 
+msgid "Undoable actions"
+msgstr ""
+
+msgid "Undoing"
+msgstr ""
+
 msgid "UniqueConstraint"
 msgstr "Restricción de Unicidad"
 
 msgid "Unreachable objects"
 msgstr "Objetos inaccesibles"
 
+#, python-format
+msgid "Updated %(etype)s : %(entity)s"
+msgstr ""
+
 msgid "Used by:"
 msgstr "Utilizado por :"
 
+#, python-format
+msgid "User %(user_eid)s on %(dt)s [%(undo_link)s] \n"
+msgstr ""
+
 msgid "Users and groups management"
 msgstr "Usuarios y grupos de administradores"
 
@@ -2073,6 +2113,9 @@
 msgid "default"
 msgstr "Valor por defecto"
 
+msgid "default /base view for (transaction) actions "
+msgstr ""
+
 msgid "default text format for rich text fields."
 msgstr ""
 "Formato de texto que se utilizará por defecto para los campos de tipo texto"
--- a/i18n/fr.po	Mon Feb 27 09:45:48 2012 +0100
+++ b/i18n/fr.po	Mon Feb 27 10:03:31 2012 +0100
@@ -4,7 +4,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: cubicweb 2.46.0\n"
-"PO-Revision-Date: 2012-02-08 17:43+0100\n"
+"PO-Revision-Date: 2012-02-15 16:08+0100\n"
 "Last-Translator: Logilab Team <contact@logilab.fr>\n"
 "Language-Team: fr <contact@logilab.fr>\n"
 "Language: \n"
@@ -147,6 +147,10 @@
 msgid "(UNEXISTANT EID)"
 msgstr "(EID INTROUVABLE)"
 
+#, python-format
+msgid "(suppressed) entity #%d"
+msgstr "entité #%d (supprimée)"
+
 msgid "**"
 msgstr "0..n 0..n"
 
@@ -219,6 +223,13 @@
 msgid "About this site"
 msgstr "À propos de ce site"
 
+msgid "Action"
+msgstr "Action"
+
+#, python-format
+msgid "Added relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr "Relation ajoutée : %(entity_from)s %(rtype)s %(entity_to)s"
+
 msgid "Any"
 msgstr "Tous"
 
@@ -415,6 +426,10 @@
 msgid "Click to sort on this column"
 msgstr "Cliquer pour trier sur cette colonne"
 
+#, python-format
+msgid "Created %(etype)s : %(entity)s"
+msgstr "Entité %(etype)s crée : %(entity)s"
+
 msgid "DEBUG"
 msgstr "DEBUG"
 
@@ -440,6 +455,14 @@
 msgid "Decimal_plural"
 msgstr "Nombres décimaux"
 
+#, python-format
+msgid "Delete relation : %(entity_from)s %(rtype)s %(entity_to)s"
+msgstr "Relation supprimée : %(entity_from)s %(rtype)s %(entity_to)s"
+
+#, python-format
+msgid "Deleted %(etype)s : %(entity)s"
+msgstr "Entité %(etype)s supprimée : %(entity)s"
+
 msgid "Detected problems"
 msgstr "Problèmes détectés"
 
@@ -752,6 +775,9 @@
 msgid "TZTime_plural"
 msgstr "Heures internationales"
 
+msgid "Target"
+msgstr ""
+
 #, python-format
 msgid "The view %s can not be applied to this query"
 msgstr "La vue %s ne peut être appliquée à cette requête"
@@ -867,15 +893,29 @@
 "URLs depuis lesquelles le contenu sera importé. Vous pouvez mettre une URL "
 "par ligne."
 
+msgid "Undoable actions"
+msgstr "Action annulables"
+
+msgid "Undoing"
+msgstr "Annuler"
+
 msgid "UniqueConstraint"
 msgstr "contrainte d'unicité"
 
 msgid "Unreachable objects"
 msgstr "Objets inaccessibles"
 
+#, python-format
+msgid "Updated %(etype)s : %(entity)s"
+msgstr "Entité %(etype)s mise à jour : %(entity)s"
+
 msgid "Used by:"
 msgstr "Utilisé par :"
 
+#, python-format
+msgid "By %(user)s on %(dt)s [%(undo_link)s]"
+msgstr "Par %(user)s le %(dt)s [%(undo_link)s] "
+
 msgid "Users and groups management"
 msgstr "Gestion des utilisateurs et groupes"
 
@@ -1398,17 +1438,23 @@
 msgid ""
 "can't restore entity %(eid)s of type %(eschema)s, target of %(rtype)s (eid "
 "%(value)s) does not exist any longer"
-msgstr "impossible de rétablir l'entité %(eid)s de type %(eschema)s, cible de la relation %(rtype)s (eid %(value)s) n'existe plus"
+msgstr ""
+"impossible de rétablir l'entité %(eid)s de type %(eschema)s, cible de la "
+"relation %(rtype)s (eid %(value)s) n'existe plus"
 
 #, python-format
 msgid ""
 "can't restore relation %(rtype)s of entity %(eid)s, this relation does not "
 "exist in the schema anymore."
-msgstr "impossible de rétablir la relation %(rtype)s sur l'entité %(eid)s, cette relation n'existe plus dans le schéma."
+msgstr ""
+"impossible de rétablir la relation %(rtype)s sur l'entité %(eid)s, cette "
+"relation n'existe plus dans le schéma."
 
 #, python-format
 msgid "can't restore state of entity %s, it has been deleted inbetween"
-msgstr "impossible de rétablir l'état de l'entité %s, elle a été supprimée entre-temps"
+msgstr ""
+"impossible de rétablir l'état de l'entité %s, elle a été supprimée entre-"
+"temps"
 
 #, python-format
 msgid ""
@@ -2079,6 +2125,9 @@
 msgid "default"
 msgstr "valeur par défaut"
 
+msgid "default /base view for (transaction) actions "
+msgstr ""
+
 msgid "default text format for rich text fields."
 msgstr "format de texte par défaut pour les champs textes"
 
--- a/web/application.py	Mon Feb 27 09:45:48 2012 +0100
+++ b/web/application.py	Mon Feb 27 10:03:31 2012 +0100
@@ -371,7 +371,9 @@
                     # no req.cnx if anonymous aren't allowed and we are
                     # displaying some anonymous enabled view such as the cookie
                     # authentication form
-                    req.cnx.commit()
+                    txuuid = req.cnx.commit()
+                    if txuuid is not None:
+                        req.data['last_undoable_transaction'] = txuuid
                     commited = True
             except (StatusResponse, DirectResponse):
                 if req.cnx:
@@ -386,9 +388,7 @@
                     if req.cnx:
                         txuuid = req.cnx.commit()
                         if txuuid is not None:
-                            msg = u'<span class="undo">[<a href="%s">%s</a>]</span>' %(
-                                req.build_url('undo', txuuid=txuuid), req._('undo'))
-                            req.append_to_redirect_message(msg)
+                            req.data['last_undoable_transaction'] = txuuid
                 except ValidationError, ex:
                     self.validation_error_handler(req, ex)
                 except Unauthorized, ex:
@@ -398,6 +398,7 @@
                 except Exception, ex:
                     self.error_handler(req, ex, tb=True)
                 else:
+                    self.add_undo_link_to_msg(req)
                     # delete validation errors which may have been previously set
                     if '__errorurl' in req.form:
                         req.session.data.pop(req.form['__errorurl'], None)
@@ -425,9 +426,17 @@
                     req.cnx.rollback()
                 except Exception:
                     pass # ignore rollback error at this point
+        self.add_undo_link_to_msg(req)
         self.info('query %s executed in %s sec', req.relative_path(), clock() - tstart)
         return result
 
+    def add_undo_link_to_msg(self, req):
+        txuuid = req.data.get('last_undoable_transaction')
+        if txuuid is not None:
+            msg = u'<span class="undo">[<a href="%s">%s</a>]</span>' %(
+            req.build_url('undo', txuuid=txuuid), req._('undo'))
+            req.append_to_redirect_message(msg)
+
     def validation_error_handler(self, req, ex):
         ex.errors = dict((k, v) for k, v in ex.errors.items())
         if '__errorurl' in req.form:
--- a/web/test/unittest_views_basecontrollers.py	Mon Feb 27 09:45:48 2012 +0100
+++ b/web/test/unittest_views_basecontrollers.py	Mon Feb 27 10:03:31 2012 +0100
@@ -19,6 +19,12 @@
 
 from __future__ import with_statement
 
+from urlparse import urlsplit, urlunsplit, urljoin
+# parse_qs is deprecated in cgi and has been moved to urlparse in Python 2.6
+try:
+    from urlparse import parse_qs as url_parse_query
+except ImportError:
+    from cgi import parse_qs as url_parse_query
 from logilab.common.testlib import unittest_main, mock_object
 from logilab.common.decorators import monkeypatch
 
@@ -32,6 +38,7 @@
 from cubicweb.web.views.autoform import get_pending_inserts, get_pending_deletes
 from cubicweb.web.views.basecontrollers import JSonController, xhtmlize, jsonize
 from cubicweb.web.views.ajaxcontroller import ajaxfunc, AjaxFunction
+import cubicweb.transaction as tx
 
 u = unicode
 
@@ -768,5 +775,66 @@
         res, req = self.remote_call('foo')
         self.assertEqual(res, '12')
 
+
+
+
+
+class UndoControllerTC(CubicWebTC):
+
+    def setup_database(self):
+        req = self.request()
+        self.session.undo_actions = True
+        self.toto = self.create_user(req, 'toto', password='toto', groups=('users',),
+                                     commit=False)
+        self.txuuid_toto = self.commit()
+        self.toto_email = self.session.create_entity('EmailAddress',
+                                       address=u'toto@logilab.org',
+                                       reverse_use_email=self.toto)
+        self.txuuid_toto_email = self.commit()
+
+    def test_no_such_transaction(self):
+        req = self.request()
+        txuuid = u"12345acbd"
+        req.form['txuuid'] = txuuid
+        controller = self.vreg['controllers'].select('undo', req)
+        with self.assertRaises(tx.NoSuchTransaction) as cm:
+            result = controller.publish(rset=None)
+        self.assertEqual(cm.exception.txuuid, txuuid)
+
+    def assertURLPath(self, url, expected_path, expected_params=None):
+        """ This assert that the path part of `url` matches  expected path
+
+        TODO : implement assertion on the expected_params too
+        """
+        req = self.request()
+        scheme, netloc, path, query, fragment = urlsplit(url)
+        query_dict = url_parse_query(query)
+        expected_url = urljoin(req.base_url(), expected_path)
+        self.assertEqual( urlunsplit((scheme, netloc, path, None, None)), expected_url)
+
+    def test_redirect_redirectpath(self):
+        "Check that the potential __redirectpath is honored"
+        req = self.request()
+        txuuid = self.txuuid_toto_email
+        req.form['txuuid'] = txuuid
+        rpath = "toto"
+        req.form['__redirectpath'] = rpath
+        controller = self.vreg['controllers'].select('undo', req)
+        with self.assertRaises(Redirect) as cm:
+            result = controller.publish(rset=None)
+        self.assertURLPath(cm.exception.location, rpath)
+
+    def test_redirect_default(self):
+        req = self.request()
+        txuuid = self.txuuid_toto_email
+        req.form['txuuid'] = txuuid
+        req.session.data['breadcrumbs'] = [ urljoin(req.base_url(), path)
+                                            for path in ('tata', 'toto',)]
+        controller = self.vreg['controllers'].select('undo', req)
+        with self.assertRaises(Redirect) as cm:
+            result = controller.publish(rset=None)
+        self.assertURLPath(cm.exception.location, 'toto')
+
+
 if __name__ == '__main__':
     unittest_main()
--- a/web/views/actions.py	Mon Feb 27 09:45:48 2012 +0100
+++ b/web/views/actions.py	Mon Feb 27 10:03:31 2012 +0100
@@ -82,6 +82,18 @@
                 return 1
     return 0
 
+class has_undoable_transactions(EntityPredicate):
+    "Select entities having public (i.e. end-user) undoable transactions."
+
+    def score_entity(self, entity):
+        if not entity._cw.vreg.config['undo-support']:
+            return 0
+        if entity._cw.cnx.undoable_transactions(eid=entity.eid):
+            return 1
+        else:
+            return 0
+
+
 # generic 'main' actions #######################################################
 
 class SelectAction(action.Action):
@@ -420,6 +432,7 @@
         self._cw.add_js('cubicweb.rhythm.js')
         return 'rhythm'
 
+
 ## default actions ui configuration ###########################################
 
 addmenu = uicfg.actionbox_appearsin_addmenu
--- a/web/views/basecontrollers.py	Mon Feb 27 09:45:48 2012 +0100
+++ b/web/views/basecontrollers.py	Mon Feb 27 10:03:31 2012 +0100
@@ -27,14 +27,14 @@
 from logilab.common.deprecation import deprecated
 
 from cubicweb import (NoSelectableObject, ObjectNotFound, ValidationError,
-                      AuthenticationError, typed_eid)
+                      AuthenticationError, typed_eid, UndoTransactionException)
 from cubicweb.utils import json_dumps
 from cubicweb.predicates import (authenticated_user, anonymous_user,
                                 match_form_params)
 from cubicweb.web import Redirect, RemoteCallFailed
-from cubicweb.web.controller import Controller
+from cubicweb.web.controller import Controller, append_url_params
 from cubicweb.web.views import vid_from_rset
-
+import cubicweb.transaction as tx
 
 @deprecated('[3.15] jsonize is deprecated, use AjaxFunction appobjects instead')
 def jsonize(func):
@@ -203,7 +203,7 @@
         return (False, _validation_error(req, ex), ctrl._edited_entity)
     except Redirect, ex:
         try:
-            req.cnx.commit() # ValidationError may be raise on commit
+            txuuid = req.cnx.commit() # ValidationError may be raised on commit
         except ValidationError, ex:
             return (False, _validation_error(req, ex), ctrl._edited_entity)
         except Exception, ex:
@@ -211,6 +211,8 @@
             req.exception('unexpected error while validating form')
             return (False, str(ex).decode('utf-8'), ctrl._edited_entity)
         else:
+            if txuuid is not None:
+                req.data['last_undoable_transaction'] = txuuid
             # complete entity: it can be used in js callbacks where we might
             # want every possible information
             if ctrl._edited_entity:
@@ -275,17 +277,17 @@
 
     def publish(self, rset=None):
         txuuid = self._cw.form['txuuid']
-        errors = self._cw.cnx.undo_transaction(txuuid)
-        if not errors:
-            self.redirect()
-        raise ValidationError(None, {None: '\n'.join(errors)})
+        try:
+            self._cw.cnx.undo_transaction(txuuid)
+        except UndoTransactionException, exc:
+            errors = exc.errors
+            #This will cause a rollback in main_publish
+            raise ValidationError(None, {None: '\n'.join(errors)})
+        else :
+            self.redirect() # Will raise Redirect
 
     def redirect(self, msg=None):
         req = self._cw
         msg = msg or req._("transaction undone")
-        breadcrumbs = req.session.data.get('breadcrumbs', None)
-        if breadcrumbs is not None and len(breadcrumbs) > 1:
-            url = req.rebuild_url(breadcrumbs[-2], __message=msg)
-        else:
-            url = req.build_url(__message=msg)
-        raise Redirect(url)
+        self._return_to_lastpage( dict(_cwmsgid= req.set_redirect_message(msg)) )
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/views/undohistory.py	Mon Feb 27 10:03:31 2012 +0100
@@ -0,0 +1,225 @@
+# copyright 2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb 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 Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+
+__docformat__ = "restructuredtext en"
+_ = unicode
+
+
+from logilab.common.registry import Predicate, yes
+
+from cubicweb import UnknownEid, tags, transaction as tx
+from cubicweb.view import View, StartupView
+from cubicweb.predicates import match_kwargs, ExpectedValuePredicate
+from cubicweb.schema import display_name
+
+
+class undoable_action(Predicate):
+    """Select only undoable actions depending on filters provided. Undo Action
+    is expected to be specified by the `tx_action` argument.
+
+    Currently the only implemented filter is:
+
+    :param action_type: chars among CUDAR (standing for Create, Update, Delete,
+                        Add, Remove)
+    """
+
+    # XXX FIXME : this selector should be completed to allow selection on the
+    # entity or relation types and public / private.
+    def __init__(self, action_type='CUDAR'):
+        assert not set(action_type) & set('CUDAR')
+        self.action_type = action_type
+
+    def __str__(self):
+        return '%s(%s)' % (self.__class__.__name__, ', '.join(
+            "%s=%v" % (str(k), str(v)) for k, v in kwargs.iteritems() ))
+
+    def __call__(self, cls, req, tx_action=None, **kwargs):
+        # tx_action is expected to be a transaction.AbstractAction
+        if not isinstance(tx_action, tx.AbstractAction):
+            return 0
+        # Filter according to action type
+        return int(tx_action.action in self.action_type)
+
+
+class UndoHistoryView(StartupView):
+    __regid__ = 'undohistory'
+    __select__ = yes()
+    title = _('Undoing')
+    item_vid = 'undoable-transaction-view'
+    cache_max_age = 0
+
+    redirect_path = 'view' #TODO
+    redirect_params = dict(vid='undohistory') #TODO
+    public_actions_only = True
+
+    # TODO Allow to choose if if want all actions or only the public ones
+    # (default)
+
+    def call(self, **kwargs):
+        txs = self._cw.cnx.undoable_transactions()
+        if txs :
+            self.w(u"<ul class='undo-transactions'>")
+            for tx in txs:
+                self.cell_call(tx)
+            self.w(u"</ul>")
+
+    def cell_call(self, tx):
+        self.w(u'<li>')
+        self.wview(self.item_vid, None, txuuid=tx.uuid,
+                   public=self.public_actions_only,
+                   redirect_path=self.redirect_path,
+                   redirect_params=self.redirect_params)
+        self.w(u'</li>\n')
+
+
+class UndoableTransactionView(View):
+    __regid__ = 'undoable-transaction-view'
+    __select__ = View.__select__ & match_kwargs('txuuid')
+
+    item_vid = 'undoable-action-list-view'
+    cache_max_age = 0
+
+    def build_undo_link(self, txuuid,
+                        redirect_path=None, redirect_params=None):
+        """ the kwargs are passed to build_url"""
+        _ = self._cw._
+        redirect = {}
+        if redirect_path:
+            redirect['__redirectpath'] = redirect_path
+        if redirect_params:
+            if isinstance(redirect_params, dict):
+                redirect['__redirectparams'] = self._cw.build_url_params(**redirect_params)
+            else:
+                redirect['__redirectparams'] = redirect_params
+        link_url = self._cw.build_url('undo', txuuid=txuuid, **redirect)
+        msg = u"<span class='undo'>%s</span>" % tags.a( _('undo'), href=link_url)
+        return msg
+
+    def call(self, txuuid, public=True,
+             redirect_path=None, redirect_params=None):
+        _ = self._cw._
+        txinfo = self._cw.cnx.transaction_info(txuuid)
+        try:
+            #XXX Under some unknown circumstances txinfo.user_eid=-1
+            user = self._cw.entity_from_eid(txinfo.user_eid)
+        except UnknownEid:
+            user = None
+        undo_url = self.build_undo_link(txuuid,
+                                        redirect_path=redirect_path,
+                                        redirect_params=redirect_params)
+        txinfo_dict = dict( dt = self._cw.format_date(txinfo.datetime, time=True),
+                            user_eid = txinfo.user_eid,
+                            user = user and user.view('outofcontext') or _("undefined user"),
+                            txuuid = txuuid,
+                            undo_link = undo_url)
+        self.w( _("By %(user)s on %(dt)s [%(undo_link)s]") % txinfo_dict)
+
+        tx_actions = txinfo.actions_list(public=public)
+        if tx_actions :
+            self.wview(self.item_vid, None, tx_actions=tx_actions)
+
+
+class UndoableActionListView(View):
+    __regid__ = 'undoable-action-list-view'
+    __select__ = View.__select__ & match_kwargs('tx_actions')
+    title = _('Undoable actions')
+    item_vid = 'undoable-action-view'
+    cache_max_age = 0
+
+    def call(self, tx_actions):
+        if tx_actions :
+            self.w(u"<ol class='undo-actions'>")
+            for action in tx_actions:
+                self.cell_call(action)
+            self.w(u"</ol>")
+
+    def cell_call(self, action):
+        self.w(u'<li>')
+        self.wview(self.item_vid, None, tx_action=action)
+        self.w(u'</li>\n')
+
+
+class UndoableActionBaseView(View):
+    __regid__ = 'undoable-action-view'
+    __abstract__ = True
+
+    def call(self, tx_action):
+        raise NotImplementedError(self)
+
+    def _build_entity_link(self, eid):
+        try:
+            entity = self._cw.entity_from_eid(eid)
+            return entity.view('outofcontext')
+        except UnknownEid:
+            return _("(suppressed) entity #%d") % eid
+
+    def _build_relation_info(self, rtype, eid_from,  eid_to):
+        return dict( rtype=display_name(self._cw, rtype),
+                     entity_from=self._build_entity_link(eid_from),
+                     entity_to=self._build_entity_link(eid_to) )
+
+    def _build_entity_info(self, etype, eid, changes):
+        return dict( etype=display_name(self._cw, etype),
+                     entity=self._build_entity_link(eid),
+                     eid=eid,
+                     changes=changes)
+
+
+class UndoableAddActionView(UndoableActionBaseView):
+    __select__ = UndoableActionBaseView.__select__ & undoable_action(action_type='A')
+
+    def call(self, tx_action):
+        _ = self._cw._
+        self.w(_("Added relation : %(entity_from)s %(rtype)s %(entity_to)s") %
+               self._build_relation_info(tx_action.rtype, tx_action.eid_from, tx_action.eid_to))
+
+
+class UndoableRemoveActionView(UndoableActionBaseView):
+    __select__ = UndoableActionBaseView.__select__ & undoable_action(action_type='R')
+
+    def call(self, tx_action):
+        _ = self._cw._
+        self.w(_("Delete relation : %(entity_from)s %(rtype)s %(entity_to)s") %
+               self._build_relation_info(tx_action.rtype, tx_action.eid_from, tx_action.eid_to))
+
+
+class UndoableCreateActionView(UndoableActionBaseView):
+    __select__ = UndoableActionBaseView.__select__ & undoable_action(action_type='C')
+
+    def call(self, tx_action):
+        _ = self._cw._
+        self.w(_("Created %(etype)s : %(entity)s") % #  : %(changes)s
+               self._build_entity_info( tx_action.etype, tx_action.eid, tx_action.changes) )
+
+
+class UndoableDeleteActionView(UndoableActionBaseView):
+    __select__ = UndoableActionBaseView.__select__ & undoable_action(action_type='D')
+
+    def call(self, tx_action):
+        _ = self._cw._
+        self.w(_("Deleted %(etype)s : %(entity)s") %
+               self._build_entity_info( tx_action.etype, tx_action.eid, tx_action.changes))
+
+
+class UndoableUpdateActionView(UndoableActionBaseView):
+    __select__ = UndoableActionBaseView.__select__ & undoable_action(action_type='U')
+
+    def call(self, tx_action):
+        _ = self._cw._
+        self.w(_("Updated %(etype)s : %(entity)s") %
+               self._build_entity_info( tx_action.etype, tx_action.eid, tx_action.changes))