server/web api for accessing to deleted_entites
authorKatia Saurfelt <katia.saurfelt@logilab.fr>
Mon, 01 Mar 2010 11:26:14 +0100
changeset 4913 083b4d454192
parent 4912 9767cc516b4f
child 4914 dcb055f32d9b
server/web api for accessing to deleted_entites
__pkginfo__.py
dataimport.py
dbapi.py
devtools/__init__.py
devtools/testlib.py
entity.py
goa/gaesource.py
hooks/__init__.py
misc/migration/3.7.0_Any.py
schema.py
server/__init__.py
server/repository.py
server/serverconfig.py
server/session.py
server/sources/__init__.py
server/sources/extlite.py
server/sources/ldapuser.py
server/sources/native.py
server/sources/pyrorql.py
server/sqlutils.py
server/test/unittest_repository.py
server/test/unittest_undo.py
test/unittest_dbapi.py
transaction.py
web/application.py
web/views/basecontrollers.py
--- a/__pkginfo__.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/__pkginfo__.py	Mon Mar 01 11:26:14 2010 +0100
@@ -7,7 +7,7 @@
 distname = "cubicweb"
 modname = "cubicweb"
 
-numversion = (3, 6, 2)
+numversion = (3, 7, 0)
 version = '.'.join(str(num) for num in numversion)
 
 license = 'LGPL'
--- a/dataimport.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/dataimport.py	Mon Mar 01 11:26:14 2010 +0100
@@ -559,6 +559,8 @@
         self._nb_inserted_types = 0
         self._nb_inserted_relations = 0
         self.rql = session.unsafe_execute
+        # disable undoing
+        session.undo_actions = frozenset()
 
     def create_entity(self, etype, **kwargs):
         for k, v in kwargs.iteritems():
--- a/dbapi.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/dbapi.py	Mon Mar 01 11:26:14 2010 +0100
@@ -57,6 +57,7 @@
     etypescls = cwvreg.VRegistry.REGISTRY_FACTORY['etypes']
     etypescls.etype_class = etypescls.orig_etype_class
 
+
 class ConnectionProperties(object):
     def __init__(self, cnxtype=None, lang=None, close=True, log=False):
         self.cnxtype = cnxtype or 'pyro'
@@ -546,16 +547,16 @@
         self._closed = 1
 
     def commit(self):
-        """Commit any pending transaction to the database. Note that if the
-        database supports an auto-commit feature, this must be initially off. An
-        interface method may be provided to turn it back on.
+        """Commit pending transaction for this connection to the repository.
 
-        Database modules that do not support transactions should implement this
-        method with void functionality.
+        may raises `Unauthorized` or `ValidationError` if we attempted to do
+        something we're not allowed to for security or integrity reason.
+
+        If the transaction is undoable, a transaction id will be returned.
         """
         if not self._closed is None:
             raise ProgrammingError('Connection is already closed')
-        self._repo.commit(self.sessionid)
+        return self._repo.commit(self.sessionid)
 
     def rollback(self):
         """This method is optional since not all databases provide transaction
@@ -582,6 +583,73 @@
             req = self.request()
         return self.cursor_class(self, self._repo, req=req)
 
+    # undo support ############################################################
+
+    def undoable_transactions(self, ueid=None, req=None, **actionfilters):
+        """Return a list of undoable transaction objects by the connection's
+        user, ordered by descendant transaction time.
+
+        Managers may filter according to user (eid) who has done the transaction
+        using the `ueid` argument. Others will only see their own transactions.
+
+        Additional filtering capabilities is provided by using the following
+        named arguments:
+
+        * `etype` to get only transactions creating/updating/deleting entities
+          of the given type
+
+        * `eid` to get only transactions applied to entity of the given eid
+
+        * `action` to get only transactions doing the given action (action in
+          'C', 'U', 'D', 'A', 'R'). If `etype`, action can only be 'C', 'U' or
+          'D'.
+
+        * `public`: when additional filtering is provided, their are by default
+          only searched in 'public' actions, unless a `public` argument is given
+          and set to false.
+        """
+        txinfos = self._repo.undoable_transactions(self.sessionid, ueid,
+                                                   **actionfilters)
+        if req is None:
+            req = self.request()
+        for txinfo in txinfos:
+            txinfo.req = req
+        return txinfos
+
+    def transaction_info(self, txuuid, req=None):
+        """Return transaction object for the given uid.
+
+        raise `NoSuchTransaction` if not found or if session's user is not
+        allowed (eg not in managers group and the transaction doesn't belong to
+        him).
+        """
+        txinfo = self._repo.transaction_info(self.sessionid, txuuid)
+        if req is None:
+            req = self.request()
+        txinfo.req = req
+        return txinfo
+
+    def transaction_actions(self, txuuid, public=True):
+        """Return an ordered list of action effectued during that transaction.
+
+        If public is true, return only 'public' actions, eg not ones triggered
+        under the cover by hooks, else return all actions.
+
+        raise `NoSuchTransaction` if the transaction is not found or if
+        session's user is not allowed (eg not in managers group and the
+        transaction doesn't belong to him).
+        """
+        return self._repo.transaction_actions(self.sessionid, txuuid, public)
+
+    def undo_transaction(self, txuuid):
+        """Undo the given transaction. Return potential restoration errors.
+
+        raise `NoSuchTransaction` if not found or if session's user is not
+        allowed (eg not in managers group and the transaction doesn't belong to
+        him).
+        """
+        return self._repo.undo_transaction(self.sessionid, txuuid)
+
 
 # cursor object ###############################################################
 
--- a/devtools/__init__.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/devtools/__init__.py	Mon Mar 01 11:26:14 2010 +0100
@@ -106,8 +106,6 @@
         self.init_log(log_threshold, force=True)
         # need this, usually triggered by cubicweb-ctl
         self.load_cwctl_plugins()
-        self.global_set_option('anonymous-user', 'anon')
-        self.global_set_option('anonymous-password', 'anon')
 
     anonymous_user = TwistedConfiguration.anonymous_user.im_func
 
@@ -123,6 +121,8 @@
         super(TestServerConfiguration, self).load_configuration()
         self.global_set_option('anonymous-user', 'anon')
         self.global_set_option('anonymous-password', 'anon')
+        # no undo support in tests
+        self.global_set_option('undo-support', '')
 
     def main_config_file(self):
         """return instance's control configuration file"""
--- a/devtools/testlib.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/devtools/testlib.py	Mon Mar 01 11:26:14 2010 +0100
@@ -321,7 +321,10 @@
 
     @nocoverage
     def commit(self):
-        self.cnx.commit()
+        try:
+            return self.cnx.commit()
+        finally:
+            self.session.set_pool() # ensure pool still set after commit
 
     @nocoverage
     def rollback(self):
@@ -329,6 +332,8 @@
             self.cnx.rollback()
         except ProgrammingError:
             pass
+        finally:
+            self.session.set_pool() # ensure pool still set after commit
 
     # # server side db api #######################################################
 
--- a/entity.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/entity.py	Mon Mar 01 11:26:14 2010 +0100
@@ -461,7 +461,7 @@
                    all(matching_groups(e.get_groups('read')) for e in targets):
                     yield rschema, 'subject'
 
-    def to_complete_attributes(self, skip_bytes=True):
+    def to_complete_attributes(self, skip_bytes=True, skip_pwd=True):
         for rschema, attrschema in self.e_schema.attribute_definitions():
             # skip binary data by default
             if skip_bytes and attrschema.type == 'Bytes':
@@ -472,13 +472,13 @@
             # password retreival is blocked at the repository server level
             rdef = rschema.rdef(self.e_schema, attrschema)
             if not self._cw.user.matching_groups(rdef.get_groups('read')) \
-                   or attrschema.type == 'Password':
+                   or (attrschema.type == 'Password' and skip_pwd):
                 self[attr] = None
                 continue
             yield attr
 
     _cw_completed = False
-    def complete(self, attributes=None, skip_bytes=True):
+    def complete(self, attributes=None, skip_bytes=True, skip_pwd=True):
         """complete this entity by adding missing attributes (i.e. query the
         repository to fill the entity)
 
@@ -495,7 +495,7 @@
         V = varmaker.next()
         rql = ['WHERE %s eid %%(x)s' % V]
         selected = []
-        for attr in (attributes or self.to_complete_attributes(skip_bytes)):
+        for attr in (attributes or self.to_complete_attributes(skip_bytes, skip_pwd)):
             # if attribute already in entity, nothing to do
             if self.has_key(attr):
                 continue
--- a/goa/gaesource.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/goa/gaesource.py	Mon Mar 01 11:26:14 2010 +0100
@@ -255,10 +255,11 @@
                 if asession.user.eid == entity.eid:
                     asession.user.update(dict(gaeentity))
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source"""
         # do not delay delete_entity as other modifications to ensure
         # consistency
+        eid = entity.eid
         key = Key(eid)
         Delete(key)
         session.clear_datastore_cache(key)
--- a/hooks/__init__.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/hooks/__init__.py	Mon Mar 01 11:26:14 2010 +0100
@@ -1,1 +1,36 @@
-"""core hooks"""
+"""core hooks
+
+:organization: Logilab
+:copyright: 2009-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
+"""
+__docformat__ = "restructuredtext en"
+
+from datetime import timedelta, datetime
+from cubicweb.server import hook
+
+class ServerStartupHook(hook.Hook):
+    """task to cleanup expirated auth cookie entities"""
+    __regid__ = 'cw_cleanup_transactions'
+    events = ('server_startup',)
+
+    def __call__(self):
+        # XXX use named args and inner functions to avoid referencing globals
+        # which may cause reloading pb
+        lifetime = timedelta(days=self.repo.config['keep-transaction-lifetime'])
+        def cleanup_old_transactions(repo=self.repo, lifetime=lifetime):
+            mindate = datetime.now() - lifetime
+            session = repo.internal_session()
+            try:
+                session.system_sql(
+                    'DELETE FROM transaction WHERE tx_time < %(time)s',
+                    {'time': mindate})
+                # cleanup deleted entities
+                session.system_sql(
+                    'DELETE FROM deleted_entities WHERE dtime < %(time)s',
+                    {'time': mindate})
+                session.commit()
+            finally:
+                session.close()
+        self.repo.looping_task(60*60*24, cleanup_old_transactions, self.repo)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/migration/3.7.0_Any.py	Mon Mar 01 11:26:14 2010 +0100
@@ -0,0 +1,40 @@
+typemap = repo.system_source.dbhelper.TYPE_MAPPING
+sqls = """
+CREATE TABLE transactions (
+  tx_uuid CHAR(32) PRIMARY KEY NOT NULL,
+  tx_user INTEGER NOT NULL,
+  tx_time %s NOT NULL
+);;
+CREATE INDEX transactions_tx_user_idx ON transactions(tx_user);;
+
+CREATE TABLE tx_entity_actions (
+  tx_uuid CHAR(32) REFERENCES transactions(tx_uuid) ON DELETE CASCADE,
+  txa_action CHAR(1) NOT NULL,
+  txa_public %s NOT NULL,
+  txa_order INTEGER,
+  eid INTEGER NOT NULL,
+  etype VARCHAR(64) NOT NULL,
+  changes %s
+);;
+CREATE INDEX tx_entity_actions_txa_action_idx ON tx_entity_actions(txa_action);;
+CREATE INDEX tx_entity_actions_txa_public_idx ON tx_entity_actions(txa_public);;
+CREATE INDEX tx_entity_actions_eid_idx ON tx_entity_actions(eid);;
+CREATE INDEX tx_entity_actions_etype_idx ON tx_entity_actions(etype);;
+
+CREATE TABLE tx_relation_actions (
+  tx_uuid CHAR(32) REFERENCES transactions(tx_uuid) ON DELETE CASCADE,
+  txa_action CHAR(1) NOT NULL,
+  txa_public %s NOT NULL,
+  txa_order INTEGER,
+  eid_from INTEGER NOT NULL,
+  eid_to INTEGER NOT NULL,
+  rtype VARCHAR(256) NOT NULL
+);;
+CREATE INDEX tx_relation_actions_txa_action_idx ON tx_relation_actions(txa_action);;
+CREATE INDEX tx_relation_actions_txa_public_idx ON tx_relation_actions(txa_public);;
+CREATE INDEX tx_relation_actions_eid_from_idx ON tx_relation_actions(eid_from);;
+CREATE INDEX tx_relation_actions_eid_to_idx ON tx_relation_actions(eid_to)
+""" % (typemap['Datetime'],
+       typemap['Boolean'], typemap['Bytes'], typemap['Boolean'])
+for statement in sqls.split(';;'):
+    sql(statement)
--- a/schema.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/schema.py	Mon Mar 01 11:26:14 2010 +0100
@@ -34,14 +34,15 @@
 PURE_VIRTUAL_RTYPES = set(('identity', 'has_text',))
 VIRTUAL_RTYPES = set(('eid', 'identity', 'has_text',))
 
-#  set of meta-relations available for every entity types
+# set of meta-relations available for every entity types
 META_RTYPES = set((
     'owned_by', 'created_by', 'is', 'is_instance_of', 'identity',
     'eid', 'creation_date', 'modification_date', 'has_text', 'cwuri',
     ))
-SYSTEM_RTYPES = set(('require_permission', 'custom_workflow', 'in_state', 'wf_info_for'))
+SYSTEM_RTYPES = set(('require_permission', 'custom_workflow', 'in_state',
+                     'wf_info_for'))
 
-#  set of entity and relation types used to build the schema
+# set of entity and relation types used to build the schema
 SCHEMA_TYPES = set((
     'CWEType', 'CWRType', 'CWAttribute', 'CWRelation',
     'CWConstraint', 'CWConstraintType', 'RQLExpression',
--- a/server/__init__.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/__init__.py	Mon Mar 01 11:26:14 2010 +0100
@@ -150,7 +150,7 @@
     schemasql = sqlschema(schema, driver)
     #skip_entities=[str(e) for e in schema.entities()
     #               if not repo.system_source.support_entity(str(e))])
-    sqlexec(schemasql, execute, pbtitle=_title)
+    sqlexec(schemasql, execute, pbtitle=_title, delimiter=';;')
     sqlcursor.close()
     sqlcnx.commit()
     sqlcnx.close()
--- a/server/repository.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/repository.py	Mon Mar 01 11:26:14 2010 +0100
@@ -24,9 +24,11 @@
 from os.path import join
 from datetime import datetime
 from time import time, localtime, strftime
+#from pickle import dumps
 
 from logilab.common.decorators import cached
 from logilab.common.compat import any
+from logilab.common import flatten
 
 from yams import BadSchemaDefinition
 from rql import RQLSyntaxError
@@ -630,7 +632,7 @@
         """commit transaction for the session with the given id"""
         self.debug('begin commit for session %s', sessionid)
         try:
-            self._get_session(sessionid).commit()
+            return self._get_session(sessionid).commit()
         except (ValidationError, Unauthorized):
             raise
         except:
@@ -679,10 +681,42 @@
           custom properties)
         """
         session = self._get_session(sessionid, setpool=False)
-        # update session properties
         for prop, value in props.items():
             session.change_property(prop, value)
 
+    def undoable_transactions(self, sessionid, ueid=None, **actionfilters):
+        """See :class:`cubicweb.dbapi.Connection.undoable_transactions`"""
+        session = self._get_session(sessionid, setpool=True)
+        try:
+            return self.system_source.undoable_transactions(session, ueid,
+                                                            **actionfilters)
+        finally:
+            session.reset_pool()
+
+    def transaction_info(self, sessionid, txuuid):
+        """See :class:`cubicweb.dbapi.Connection.transaction_info`"""
+        session = self._get_session(sessionid, setpool=True)
+        try:
+            return self.system_source.tx_info(session, txuuid)
+        finally:
+            session.reset_pool()
+
+    def transaction_actions(self, sessionid, txuuid, public=True):
+        """See :class:`cubicweb.dbapi.Connection.transaction_actions`"""
+        session = self._get_session(sessionid, setpool=True)
+        try:
+            return self.system_source.tx_actions(session, txuuid, public)
+        finally:
+            session.reset_pool()
+
+    def undo_transaction(self, sessionid, txuuid):
+        """See :class:`cubicweb.dbapi.Connection.undo_transaction`"""
+        session = self._get_session(sessionid, setpool=True)
+        try:
+            return self.system_source.undo_transaction(session, txuuid)
+        finally:
+            session.reset_pool()
+
     # public (inter-repository) interface #####################################
 
     def entities_modified_since(self, etypes, mtime):
@@ -886,60 +920,58 @@
         self.system_source.add_info(session, entity, source, extid, complete)
         CleanupEidTypeCacheOp(session)
 
-    def delete_info(self, session, eid):
-        self._prepare_delete_info(session, eid)
-        self._delete_info(session, eid)
+    def delete_info(self, session, entity, sourceuri, extid):
+        """called by external source when some entity known by the system source
+        has been deleted in the external source
+        """
+        self._prepare_delete_info(session, entity, sourceuri)
+        self._delete_info(session, entity, sourceuri, extid)
 
-    def _prepare_delete_info(self, session, eid):
+    def _prepare_delete_info(self, session, entity, sourceuri):
         """prepare the repository for deletion of an entity:
         * update the fti
         * mark eid as being deleted in session info
         * setup cache update operation
+        * if undoable, get back all entity's attributes and relation
         """
+        eid = entity.eid
         self.system_source.fti_unindex_entity(session, eid)
         pending = session.transaction_data.setdefault('pendingeids', set())
         pending.add(eid)
         CleanupEidTypeCacheOp(session)
 
-    def _delete_info(self, session, eid):
+    def _delete_info(self, session, entity, sourceuri, extid):
+                     # attributes=None, relations=None):
         """delete system information on deletion of an entity:
-        * delete all relations on this entity
-        * transfer record from the entities table to the deleted_entities table
+        * delete all remaining relations from/to this entity
+        * call delete info on the system source which will transfer record from
+          the entities table to the deleted_entities table
         """
-        etype, uri, extid = self.type_and_source_from_eid(eid, session)
-        self._clear_eid_relations(session, etype, eid)
-        self.system_source.delete_info(session, eid, etype, uri, extid)
-
-    def _clear_eid_relations(self, session, etype, eid):
-        """when a entity is deleted, build and execute rql query to delete all
-        its relations
-        """
-        rql = []
-        eschema = self.schema.eschema(etype)
         pendingrtypes = session.transaction_data.get('pendingrtypes', ())
+        # delete remaining relations: if user can delete the entity, he can
+        # delete all its relations without security checking
         with security_enabled(session, read=False, write=False):
-            for rschema, targetschemas, x in eschema.relation_definitions():
+            eid = entity.eid
+            for rschema, _, role in entity.e_schema.relation_definitions():
                 rtype = rschema.type
                 if rtype in schema.VIRTUAL_RTYPES or rtype in pendingrtypes:
                     continue
-                var = '%s%s' % (rtype.upper(), x.upper())
-                if x == 'subject':
+                if role == 'subject':
                     # don't skip inlined relation so they are regularly
                     # deleted and so hooks are correctly called
-                    selection = 'X %s %s' % (rtype, var)
+                    selection = 'X %s Y' % rtype
                 else:
-                    selection = '%s %s X' % (var, rtype)
+                    selection = 'Y %s X' % rtype
                 rql = 'DELETE %s WHERE X eid %%(x)s' % selection
-                # if user can delete the entity, he can delete all its relations
-                # without security checking
                 session.execute(rql, {'x': eid}, 'x', build_descr=False)
+        self.system_source.delete_info(session, entity, sourceuri, extid)
 
     def locate_relation_source(self, session, subject, rtype, object):
         subjsource = self.source_from_eid(subject, session)
         objsource = self.source_from_eid(object, session)
         if not subjsource is objsource:
             source = self.system_source
-            if not (subjsource.may_cross_relation(rtype) 
+            if not (subjsource.may_cross_relation(rtype)
                     and objsource.may_cross_relation(rtype)):
                 raise MultiSourcesError(
                     "relation %s can't be crossed among sources"
@@ -983,7 +1015,7 @@
             self.hm.call_hooks('before_add_entity', session, entity=entity)
         # XXX use entity.keys here since edited_attributes is not updated for
         # inline relations
-        for attr in entity.keys():
+        for attr in entity.iterkeys():
             rschema = eschema.subjrels[attr]
             if not rschema.final: # inlined relation
                 relations.append((attr, entity[attr]))
@@ -1094,19 +1126,16 @@
 
     def glob_delete_entity(self, session, eid):
         """delete an entity and all related entities from the repository"""
-        # call delete_info before hooks
-        self._prepare_delete_info(session, eid)
-        etype, uri, extid = self.type_and_source_from_eid(eid, session)
+        entity = session.entity_from_eid(eid)
+        etype, sourceuri, extid = self.type_and_source_from_eid(eid, session)
+        self._prepare_delete_info(session, entity, sourceuri)
         if server.DEBUG & server.DBG_REPO:
             print 'DELETE entity', etype, eid
-            if eid == 937:
-                server.DEBUG |= (server.DBG_SQL | server.DBG_RQL | server.DBG_MORE)
-        source = self.sources_by_uri[uri]
+        source = self.sources_by_uri[sourceuri]
         if source.should_call_hooks:
-            entity = session.entity_from_eid(eid)
             self.hm.call_hooks('before_delete_entity', session, entity=entity)
-        self._delete_info(session, eid)
-        source.delete_entity(session, etype, eid)
+        self._delete_info(session, entity, sourceuri, extid)
+        source.delete_entity(session, entity)
         if source.should_call_hooks:
             self.hm.call_hooks('after_delete_entity', session, entity=entity)
         # don't clear cache here this is done in a hook on commit
--- a/server/serverconfig.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/serverconfig.py	Mon Mar 01 11:26:14 2010 +0100
@@ -127,6 +127,20 @@
           'help': 'size of the parsed rql cache size.',
           'group': 'main', 'inputlevel': 1,
           }),
+        ('undo-support',
+         {'type' : 'string', 'default': '',
+          'help': 'string defining actions that will have undo support: \
+[C]reate [U]pdate [D]elete entities / [A]dd [R]emove relation. Leave it empty \
+for no undo support, set it to CUDAR for full undo support, or to DR for \
+support undoing of deletion only.',
+          'group': 'main', 'inputlevel': 1,
+          }),
+        ('keep-transaction-lifetime',
+         {'type' : 'int', 'default': 7,
+          'help': 'number of days during which transaction records should be \
+kept (hence undoable).',
+          'group': 'main', 'inputlevel': 1,
+          }),
         ('delay-full-text-indexation',
          {'type' : 'yn', 'default': False,
           'help': 'When full text indexation of entity has a too important cost'
--- a/server/session.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/session.py	Mon Mar 01 11:26:14 2010 +0100
@@ -12,12 +12,13 @@
 import sys
 import threading
 from time import time
+from uuid import uuid4
 
 from logilab.common.deprecation import deprecated
 from rql.nodes import VariableRef, Function, ETYPE_PYOBJ_MAP, etype_from_pyobj
 from yams import BASE_TYPES
 
-from cubicweb import Binary, UnknownEid
+from cubicweb import Binary, UnknownEid, schema
 from cubicweb.req import RequestSessionBase
 from cubicweb.dbapi import ConnectionProperties
 from cubicweb.utils import make_uid
@@ -25,6 +26,10 @@
 
 ETYPE_PYOBJ_MAP[Binary] = 'Bytes'
 
+NO_UNDO_TYPES = schema.SCHEMA_TYPES.copy()
+NO_UNDO_TYPES.add('CWCache')
+# XXX rememberme,forgotpwd,apycot,vcsfile
+
 def is_final(rqlst, variable, args):
     # try to find if this is a final var or not
     for select in rqlst.children:
@@ -110,6 +115,7 @@
     """tie session id, user, connections pool and other session data all
     together
     """
+    is_internal_session = False
 
     def __init__(self, user, repo, cnxprops=None, _id=None):
         super(Session, self).__init__(repo.vreg)
@@ -120,8 +126,14 @@
         self.cnxtype = cnxprops.cnxtype
         self.creation = time()
         self.timestamp = self.creation
-        self.is_internal_session = False
         self.default_mode = 'read'
+        # support undo for Create Update Delete entity / Add Remove relation
+        if repo.config.creating or repo.config.repairing or self.is_internal_session:
+            self.undo_actions = ()
+        else:
+            self.undo_actions = set(repo.config['undo-support'].upper())
+            if self.undo_actions - set('CUDAR'):
+                raise Exception('bad undo-support string in configuration')
         # short cut to querier .execute method
         self._execute = repo.querier.execute
         # shared data, used to communicate extra information between the client
@@ -334,7 +346,10 @@
         # so we can't rely on simply checking session.read_security, but
         # recalling the first transition from DEFAULT_SECURITY to something
         # else (False actually) is not perfect but should be enough
-        self._threaddata.dbapi_query = oldmode is self.DEFAULT_SECURITY
+        #
+        # also reset dbapi_query to true when we go back to DEFAULT_SECURITY
+        self._threaddata.dbapi_query = (oldmode is self.DEFAULT_SECURITY
+                                        or activated is self.DEFAULT_SECURITY)
         return oldmode
 
     @property
@@ -689,6 +704,7 @@
                         self.critical('error while %sing', trstate,
                                       exc_info=sys.exc_info())
                 self.info('%s session %s done', trstate, self.id)
+                return self.transaction_uuid(set=False)
             finally:
                 self._clear_thread_data()
                 self._touch()
@@ -769,6 +785,27 @@
         else:
             self.pending_operations.insert(index, operation)
 
+    # undo support ############################################################
+
+    def undoable_action(self, action, ertype):
+        return action in self.undo_actions and not ertype in NO_UNDO_TYPES
+        # XXX elif transaction on mark it partial
+
+    def transaction_uuid(self, set=True):
+        try:
+            return self.transaction_data['tx_uuid']
+        except KeyError:
+            if not set:
+                return
+            self.transaction_data['tx_uuid'] = uuid = uuid4().hex
+            self.repo.system_source.start_undoable_transaction(self, uuid)
+            return uuid
+
+    def transaction_inc_action_counter(self):
+        num = self.transaction_data.setdefault('tx_action_count', 0) + 1
+        self.transaction_data['tx_action_count'] = num
+        return num
+
     # querier helpers #########################################################
 
     @property
@@ -890,13 +927,13 @@
 
 class InternalSession(Session):
     """special session created internaly by the repository"""
+    is_internal_session = True
 
     def __init__(self, repo, cnxprops=None):
         super(InternalSession, self).__init__(InternalManager(), repo, cnxprops,
                                               _id='internal')
         self.user.req = self # XXX remove when "vreg = user.req.vreg" hack in entity.py is gone
         self.cnxtype = 'inmemory'
-        self.is_internal_session = True
         self.disable_hook_categories('integrity')
 
 
--- a/server/sources/__init__.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sources/__init__.py	Mon Mar 01 11:26:14 2010 +0100
@@ -351,7 +351,7 @@
         """update an entity in the source"""
         raise NotImplementedError()
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source"""
         raise NotImplementedError()
 
@@ -372,11 +372,15 @@
     def create_eid(self, session):
         raise NotImplementedError()
 
-    def add_info(self, session, entity, source, extid=None):
+    def add_info(self, session, entity, source, extid):
         """add type and source info for an eid into the system table"""
         raise NotImplementedError()
 
-    def delete_info(self, session, eid, etype, uri, extid):
+    def update_info(self, session, entity, need_fti_update):
+        """mark entity as being modified, fulltext reindex if needed"""
+        raise NotImplementedError()
+
+    def delete_info(self, session, entity, uri, extid, attributes, relations):
         """delete system information on deletion of an entity by transfering
         record from the entities table to the deleted_entities table
         """
--- a/server/sources/extlite.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sources/extlite.py	Mon Mar 01 11:26:14 2010 +0100
@@ -225,15 +225,15 @@
         """update an entity in the source"""
         raise NotImplementedError()
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source
 
         this is not deleting a file in the svn but deleting entities from the
         source. Main usage is to delete repository content when a Repository
         entity is deleted.
         """
-        attrs = {SQL_PREFIX + 'eid': eid}
-        sql = self.sqladapter.sqlgen.delete(SQL_PREFIX + etype, attrs)
+        attrs = {'cw_eid': entity.eid}
+        sql = self.sqladapter.sqlgen.delete(SQL_PREFIX + entity.__regid__, attrs)
         self.doexec(session, sql, attrs)
 
     def local_add_relation(self, session, subject, rtype, object):
--- a/server/sources/ldapuser.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sources/ldapuser.py	Mon Mar 01 11:26:14 2010 +0100
@@ -476,7 +476,8 @@
             if eid:
                 self.warning('deleting ldap user with eid %s and dn %s',
                              eid, base)
-                self.repo.delete_info(session, eid)
+                entity = session.entity_from_eid(eid, 'CWUser')
+                self.repo.delete_info(session, entity, self.uri, base)
                 self._cache.pop(base, None)
             return []
 ##         except ldap.REFERRAL, e:
@@ -554,7 +555,7 @@
         """replace an entity in the source"""
         raise RepositoryError('this source is read only')
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source"""
         raise RepositoryError('this source is read only')
 
--- a/server/sources/native.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sources/native.py	Mon Mar 01 11:26:14 2010 +0100
@@ -11,8 +11,11 @@
 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
 :license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
 """
+from __future__ import with_statement
+
 __docformat__ = "restructuredtext en"
 
+from pickle import loads, dumps
 from threading import Lock
 from datetime import datetime
 from base64 import b64decode, b64encode
@@ -24,12 +27,15 @@
 from logilab.common.shellutils import getlogin
 from logilab.database import get_db_helper
 
-from cubicweb import UnknownEid, AuthenticationError, Binary, server
+from cubicweb import UnknownEid, AuthenticationError, Binary, server, neg_role
+from cubicweb import transaction as tx
+from cubicweb.schema import VIRTUAL_RTYPES
 from cubicweb.cwconfig import CubicWebNoAppConfiguration
 from cubicweb.server import hook
 from cubicweb.server.utils import crypt_password
 from cubicweb.server.sqlutils import SQL_PREFIX, SQLAdapterMixIn
 from cubicweb.server.rqlannotation import set_qdata
+from cubicweb.server.session import hooks_control
 from cubicweb.server.sources import AbstractSource, dbg_st_search, dbg_results
 from cubicweb.server.sources.rql2sql import SQLGenerator
 
@@ -93,6 +99,35 @@
         table, restr, attr)
 
 
+def sql_or_clauses(sql, clauses):
+    select, restr = sql.split(' WHERE ', 1)
+    restrclauses = restr.split(' AND ')
+    for clause in clauses:
+        restrclauses.remove(clause)
+    if restrclauses:
+        restr = '%s AND (%s)' % (' AND '.join(restrclauses),
+                                 ' OR '.join(clauses))
+    else:
+        restr = '(%s)' % ' OR '.join(clauses)
+    return '%s WHERE %s' % (select, restr)
+
+
+class UndoException(Exception):
+    """something went wrong during undoing"""
+
+
+def _undo_check_relation_target(tentity, rdef, role):
+    """check linked entity has not been redirected for this relation"""
+    card = rdef.role_cardinality(role)
+    if card in '?1' and tentity.related(rdef.rtype, role):
+        raise UndoException(tentity._cw._(
+            "Can't restore %(role)s relation %(rtype)s to entity %(eid)s which "
+            "is already linked using this relation.")
+                            % {'role': neg_role(role),
+                               'rtype': rdef.rtype,
+                               'eid': tentity.eid})
+
+
 class NativeSQLSource(SQLAdapterMixIn, AbstractSource):
     """adapter for source using the native cubicweb schema (see below)
     """
@@ -370,34 +405,57 @@
     def add_entity(self, session, entity):
         """add a new entity to the source"""
         attrs = self.preprocess_entity(entity)
-        sql = self.sqlgen.insert(SQL_PREFIX + str(entity.e_schema), attrs)
+        sql = self.sqlgen.insert(SQL_PREFIX + entity.__regid__, attrs)
         self.doexec(session, sql, attrs)
+        if session.undoable_action('C', entity.__regid__):
+            self._record_tx_action(session, 'tx_entity_actions', 'C',
+                                   etype=entity.__regid__, eid=entity.eid)
 
     def update_entity(self, session, entity):
         """replace an entity in the source"""
         attrs = self.preprocess_entity(entity)
-        sql = self.sqlgen.update(SQL_PREFIX + str(entity.e_schema), attrs,
-                                 [SQL_PREFIX + 'eid'])
+        if session.undoable_action('U', entity.__regid__):
+            changes = self._save_attrs(session, entity, attrs)
+            self._record_tx_action(session, 'tx_entity_actions', 'U',
+                                   etype=entity.__regid__, eid=entity.eid,
+                                   changes=self._binary(dumps(changes)))
+        sql = self.sqlgen.update(SQL_PREFIX + entity.__regid__, attrs,
+                                 ['cw_eid'])
         self.doexec(session, sql, attrs)
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source"""
-        attrs = {SQL_PREFIX + 'eid': eid}
-        sql = self.sqlgen.delete(SQL_PREFIX + etype, attrs)
+        if session.undoable_action('D', entity.__regid__):
+            attrs = [SQL_PREFIX + r.type
+                     for r in entity.e_schema.subject_relations()
+                     if (r.final or r.inlined) and not r in VIRTUAL_RTYPES]
+            changes = self._save_attrs(session, entity, attrs)
+            self._record_tx_action(session, 'tx_entity_actions', 'D',
+                                   etype=entity.__regid__, eid=entity.eid,
+                                   changes=self._binary(dumps(changes)))
+        attrs = {'cw_eid': entity.eid}
+        sql = self.sqlgen.delete(SQL_PREFIX + entity.__regid__, attrs)
         self.doexec(session, sql, attrs)
 
-    def add_relation(self, session, subject, rtype, object, inlined=False):
+    def _add_relation(self, session, subject, rtype, object, inlined=False):
         """add a relation to the source"""
         if inlined is False:
             attrs = {'eid_from': subject, 'eid_to': object}
             sql = self.sqlgen.insert('%s_relation' % rtype, attrs)
         else: # used by data import
             etype = session.describe(subject)[0]
-            attrs = {SQL_PREFIX + 'eid': subject, SQL_PREFIX + rtype: object}
+            attrs = {'cw_eid': subject, SQL_PREFIX + rtype: object}
             sql = self.sqlgen.update(SQL_PREFIX + etype, attrs,
-                                     [SQL_PREFIX + 'eid'])
+                                     ['cw_eid'])
         self.doexec(session, sql, attrs)
 
+    def add_relation(self, session, subject, rtype, object, inlined=False):
+        """add a relation to the source"""
+        self._add_relation(session, subject, rtype, object, inlined)
+        if session.undoable_action('A', rtype):
+            self._record_tx_action(session, 'tx_relation_actions', 'A',
+                                   eid_from=subject, rtype=rtype, eid_to=object)
+
     def delete_relation(self, session, subject, rtype, object):
         """delete a relation from the source"""
         rschema = self.schema.rschema(rtype)
@@ -411,6 +469,9 @@
             attrs = {'eid_from': subject, 'eid_to': object}
             sql = self.sqlgen.delete('%s_relation' % rtype, attrs)
         self.doexec(session, sql, attrs)
+        if session.undoable_action('R', rtype):
+            self._record_tx_action(session, 'tx_relation_actions', 'R',
+                                   eid_from=subject, rtype=rtype, eid_to=object)
 
     def doexec(self, session, query, args=None, rollback=True):
         """Execute a query.
@@ -467,6 +528,9 @@
 
     # short cut to method requiring advanced db helper usage ##################
 
+    def binary_to_str(self, value):
+        return self.dbhelper.dbapi_module.binary_to_str(value)
+
     def create_index(self, session, table, column, unique=False):
         cursor = LogCursor(session.pool[self.uri])
         self.dbhelper.create_index(cursor, table, column, unique)
@@ -481,7 +545,7 @@
         """return a tuple (type, source, extid) for the entity with id <eid>"""
         sql = 'SELECT type, source, extid FROM entities WHERE eid=%s' % eid
         try:
-            res = session.system_sql(sql).fetchone()
+            res = self.doexec(session, sql).fetchone()
         except:
             assert session.pool, 'session has no pool set'
             raise UnknownEid(eid)
@@ -496,9 +560,10 @@
     def extid2eid(self, session, source, extid):
         """get eid from an external id. Return None if no record found."""
         assert isinstance(extid, str)
-        cursor = session.system_sql('SELECT eid FROM entities WHERE '
-                                    'extid=%(x)s AND source=%(s)s',
-                                    {'x': b64encode(extid), 's': source.uri})
+        cursor = self.doexec(session,
+                             'SELECT eid FROM entities '
+                             'WHERE extid=%(x)s AND source=%(s)s',
+                             {'x': b64encode(extid), 's': source.uri})
         # XXX testing rowcount cause strange bug with sqlite, results are there
         #     but rowcount is 0
         #if cursor.rowcount > 0:
@@ -529,7 +594,7 @@
         finally:
             self._eid_creation_lock.release()
 
-    def add_info(self, session, entity, source, extid=None, complete=True):
+    def add_info(self, session, entity, source, extid, complete):
         """add type and source info for an eid into the system table"""
         # begin by inserting eid/type/source/extid into the entities table
         if extid is not None:
@@ -537,7 +602,7 @@
             extid = b64encode(extid)
         attrs = {'type': entity.__regid__, 'eid': entity.eid, 'extid': extid,
                  'source': source.uri, 'mtime': datetime.now()}
-        session.system_sql(self.sqlgen.insert('entities', attrs), attrs)
+        self.doexec(session, self.sqlgen.insert('entities', attrs), attrs)
         # now we can update the full text index
         if self.do_fti and self.need_fti_indexation(entity.__regid__):
             if complete:
@@ -545,26 +610,28 @@
             FTIndexEntityOp(session, entity=entity)
 
     def update_info(self, session, entity, need_fti_update):
+        """mark entity as being modified, fulltext reindex if needed"""
         if self.do_fti and need_fti_update:
             # reindex the entity only if this query is updating at least
             # one indexable attribute
             FTIndexEntityOp(session, entity=entity)
         # update entities.mtime
         attrs = {'eid': entity.eid, 'mtime': datetime.now()}
-        session.system_sql(self.sqlgen.update('entities', attrs, ['eid']), attrs)
+        self.doexec(session, self.sqlgen.update('entities', attrs, ['eid']), attrs)
 
-    def delete_info(self, session, eid, etype, uri, extid):
+    def delete_info(self, session, entity, uri, extid):
         """delete system information on deletion of an entity by transfering
         record from the entities table to the deleted_entities table
         """
-        attrs = {'eid': eid}
-        session.system_sql(self.sqlgen.delete('entities', attrs), attrs)
+        attrs = {'eid': entity.eid}
+        self.doexec(session, self.sqlgen.delete('entities', attrs), attrs)
         if extid is not None:
             assert isinstance(extid, str), type(extid)
             extid = b64encode(extid)
-        attrs = {'type': etype, 'eid': eid, 'extid': extid,
-                 'source': uri, 'dtime': datetime.now()}
-        session.system_sql(self.sqlgen.insert('deleted_entities', attrs), attrs)
+        attrs = {'type': entity.__regid__, 'eid': entity.eid, 'extid': extid,
+                 'source': uri, 'dtime': datetime.now(),
+                 }
+        self.doexec(session, self.sqlgen.insert('deleted_entities', attrs), attrs)
 
     def modified_entities(self, session, etypes, mtime):
         """return a 2-uple:
@@ -575,13 +642,315 @@
           deleted since the given timestamp
         """
         modsql = _modified_sql('entities', etypes)
-        cursor = session.system_sql(modsql, {'time': mtime})
+        cursor = self.doexec(session, modsql, {'time': mtime})
         modentities = cursor.fetchall()
         delsql = _modified_sql('deleted_entities', etypes)
-        cursor = session.system_sql(delsql, {'time': mtime})
+        cursor = self.doexec(session, delsql, {'time': mtime})
         delentities = cursor.fetchall()
         return modentities, delentities
 
+    # undo support #############################################################
+
+    def undoable_transactions(self, session, ueid=None, **actionfilters):
+        """See :class:`cubicweb.dbapi.Connection.undoable_transactions`"""
+        # force filtering to session's user if not a manager
+        if not session.user.is_in_group('managers'):
+            ueid = session.user.eid
+        restr = {}
+        if ueid is not None:
+            restr['tx_user'] = ueid
+        sql = self.sqlgen.select('transactions', restr, ('tx_uuid', 'tx_time', 'tx_user'))
+        if actionfilters:
+            # we will need subqueries to filter transactions according to
+            # actions done
+            tearestr = {} # filters on the tx_entity_actions table
+            trarestr = {} # filters on the tx_relation_actions table
+            genrestr = {} # generic filters, appliyable to both table
+            # unless public explicitly set to false, we only consider public
+            # actions
+            if actionfilters.pop('public', True):
+                genrestr['txa_public'] = True
+            # put additional filters in trarestr and/or tearestr
+            for key, val in actionfilters.iteritems():
+                if key == 'etype':
+                    # filtering on etype implies filtering on entity actions
+                    # only, and with no eid specified
+                    assert actionfilters.get('action', 'C') in 'CUD'
+                    assert not 'eid' in actionfilters
+                    tearestr['etype'] = val
+                elif key == 'eid':
+                    # eid filter may apply to 'eid' of tx_entity_actions or to
+                    # 'eid_from' OR 'eid_to' of tx_relation_actions
+                    if actionfilters.get('action', 'C') in 'CUD':
+                        tearestr['eid'] = val
+                    if actionfilters.get('action', 'A') in 'AR':
+                        trarestr['eid_from'] = val
+                        trarestr['eid_to'] = val
+                elif key == 'action':
+                    if val in 'CUD':
+                        tearestr['txa_action'] = val
+                    else:
+                        assert val in 'AR'
+                        trarestr['txa_action'] = val
+                else:
+                    raise AssertionError('unknow filter %s' % key)
+            assert trarestr or tearestr, "can't only filter on 'public'"
+            subqsqls = []
+            # append subqueries to the original query, using EXISTS()
+            if trarestr or (genrestr and not tearestr):
+                trarestr.update(genrestr)
+                trasql = self.sqlgen.select('tx_relation_actions', trarestr, ('1',))
+                if 'eid_from' in trarestr:
+                    # replace AND by OR between eid_from/eid_to restriction
+                    trasql = sql_or_clauses(trasql, ['eid_from = %(eid_from)s',
+                                                     'eid_to = %(eid_to)s'])
+                trasql += ' AND transactions.tx_uuid=tx_relation_actions.tx_uuid'
+                subqsqls.append('EXISTS(%s)' % trasql)
+            if tearestr or (genrestr and not trarestr):
+                tearestr.update(genrestr)
+                teasql = self.sqlgen.select('tx_entity_actions', tearestr, ('1',))
+                teasql += ' AND transactions.tx_uuid=tx_entity_actions.tx_uuid'
+                subqsqls.append('EXISTS(%s)' % teasql)
+            if restr:
+                sql += ' AND %s' % ' OR '.join(subqsqls)
+            else:
+                sql += ' WHERE %s' % ' OR '.join(subqsqls)
+            restr.update(trarestr)
+            restr.update(tearestr)
+        # we want results ordered by transaction's time descendant
+        sql += ' ORDER BY tx_time DESC'
+        cu = self.doexec(session, sql, restr)
+        # turn results into transaction objects
+        return [tx.Transaction(*args) for args in cu.fetchall()]
+
+    def tx_info(self, session, txuuid):
+        """See :class:`cubicweb.dbapi.Connection.transaction_info`"""
+        return tx.Transaction(txuuid, *self._tx_info(session, txuuid))
+
+    def tx_actions(self, session, txuuid, public):
+        """See :class:`cubicweb.dbapi.Connection.transaction_actions`"""
+        self._tx_info(session, txuuid)
+        restr = {'tx_uuid': txuuid}
+        if public:
+            restr['txa_public'] = True
+        sql = self.sqlgen.select('tx_entity_actions', restr,
+                                 ('txa_action', 'txa_public', 'txa_order',
+                                  'etype', 'eid', 'changes'))
+        cu = self.doexec(session, sql, restr)
+        actions = [tx.EntityAction(a,p,o,et,e,c and loads(self.binary_to_str(c)))
+                   for a,p,o,et,e,c in cu.fetchall()]
+        sql = self.sqlgen.select('tx_relation_actions', restr,
+                                 ('txa_action', 'txa_public', 'txa_order',
+                                  'rtype', 'eid_from', 'eid_to'))
+        cu = self.doexec(session, sql, restr)
+        actions += [tx.RelationAction(*args) for args in cu.fetchall()]
+        return sorted(actions, key=lambda x: x.order)
+
+    def undo_transaction(self, session, txuuid):
+        """See :class:`cubicweb.dbapi.Connection.undo_transaction`"""
+        # set mode so pool isn't released subsquently until commit/rollback
+        session.mode = 'write'
+        errors = []
+        with hooks_control(session, session.HOOKS_DENY_ALL, 'integrity'):
+            for action in reversed(self.tx_actions(session, txuuid, False)):
+                undomethod = getattr(self, '_undo_%s' % action.action.lower())
+                errors += undomethod(session, action)
+        # remove the transactions record
+        self.doexec(session,
+                    "DELETE FROM transactions WHERE tx_uuid='%s'" % txuuid)
+        return errors
+
+    def start_undoable_transaction(self, session, uuid):
+        """session callback to insert a transaction record in the transactions
+        table when some undoable transaction is started
+        """
+        ueid = session.user.eid
+        attrs = {'tx_uuid': uuid, 'tx_user': ueid, 'tx_time': datetime.now()}
+        self.doexec(session, self.sqlgen.insert('transactions', attrs), attrs)
+
+    def _save_attrs(self, session, entity, attrs):
+        """return a pickleable dictionary containing current values for given
+        attributes of the entity
+        """
+        restr = {'cw_eid': entity.eid}
+        sql = self.sqlgen.select(SQL_PREFIX + entity.__regid__, restr, attrs)
+        cu = self.doexec(session, sql, restr)
+        values = dict(zip(attrs, cu.fetchone()))
+        # ensure backend specific binary are converted back to string
+        eschema = entity.e_schema
+        for column in attrs:
+            # [3:] remove 'cw_' prefix
+            attr = column[3:]
+            if not eschema.subjrels[attr].final:
+                continue
+            if eschema.destination(attr) in ('Password', 'Bytes'):
+                value = values[column]
+                if value is not None:
+                    values[column] = self.binary_to_str(value)
+        return values
+
+    def _record_tx_action(self, session, table, action, **kwargs):
+        """record a transaction action in the given table (either
+        'tx_entity_actions' or 'tx_relation_action')
+        """
+        kwargs['tx_uuid'] = session.transaction_uuid()
+        kwargs['txa_action'] = action
+        kwargs['txa_order'] = session.transaction_inc_action_counter()
+        kwargs['txa_public'] = session.running_dbapi_query
+        self.doexec(session, self.sqlgen.insert(table, kwargs), kwargs)
+
+    def _tx_info(self, session, txuuid):
+        """return transaction's time and user of the transaction with the given uuid.
+
+        raise `NoSuchTransaction` if there is no such transaction of if the
+        session's user isn't allowed to see it.
+        """
+        restr = {'tx_uuid': txuuid}
+        sql = self.sqlgen.select('transactions', restr, ('tx_time', 'tx_user'))
+        cu = self.doexec(session, sql, restr)
+        try:
+            time, ueid = cu.fetchone()
+        except TypeError:
+            raise tx.NoSuchTransaction()
+        if not (session.user.is_in_group('managers')
+                or session.user.eid == ueid):
+            raise tx.NoSuchTransaction()
+        return time, ueid
+
+    def _undo_d(self, session, action):
+        """undo an entity deletion"""
+        errors = []
+        err = errors.append
+        eid = action.eid
+        etype = action.etype
+        _ = session._
+        # get an entity instance
+        try:
+            entity = self.repo.vreg['etypes'].etype_class(etype)(session)
+        except Exception:
+            err("can't restore entity %s of type %s, type no more supported"
+                % (eid, etype))
+            return errors
+        # check for schema changes, entities linked through inlined relation
+        # still exists, rewrap binary values
+        eschema = entity.e_schema
+        getrschema = eschema.subjrels
+        for column, value in action.changes.items():
+            rtype = column[3:] # remove cw_ prefix
+            try:
+                rschema = getrschema[rtype]
+            except KeyError:
+                err(_("Can't restore relation %(rtype)s of entity %(eid)s, "
+                      "this relation does not exists anymore in the schema.")
+                    % {'rtype': rtype, 'eid': eid})
+            if not rschema.final:
+                assert value is None
+                    # try:
+                    #     tentity = session.entity_from_eid(eid)
+                    # except UnknownEid:
+                    #     err(_("Can't restore %(role)s relation %(rtype)s to "
+                    #           "entity %(eid)s which doesn't exist anymore.")
+                    #         % {'role': _('subject'),
+                    #            'rtype': _(rtype),
+                    #            'eid': eid})
+                    #     continue
+                    # rdef = rdefs[(eschema, tentity.__regid__)]
+                    # try:
+                    #     _undo_check_relation_target(tentity, rdef, 'object')
+                    # except UndoException, ex:
+                    #     err(unicode(ex))
+                    #     continue
+                    # if rschema.inlined:
+                    #     entity[rtype] = value
+                    # else:
+                    #     # restore relation where inlined changed since the deletion
+                    #     del action.changes[column]
+                    #     self._add_relation(session, subject, rtype, object)
+                    # # set related cache
+                    # session.update_rel_cache_add(eid, rtype, value,
+                    #                              rschema.symmetric)
+            elif eschema.destination(rtype) in ('Bytes', 'Password'):
+                action.changes[column] = self._binary(value)
+                entity[rtype] = Binary(value)
+            elif isinstance(value, str):
+                entity[rtype] = unicode(value, session.encoding, 'replace')
+            else:
+                entity[rtype] = value
+        entity.set_eid(eid)
+        entity.edited_attributes = set(entity)
+        entity.check()
+        self.repo.hm.call_hooks('before_add_entity', session, entity=entity)
+        # restore the entity
+        action.changes['cw_eid'] = eid
+        sql = self.sqlgen.insert(SQL_PREFIX + etype, action.changes)
+        self.doexec(session, sql, action.changes)
+        # restore record in entities (will update fti if needed)
+        self.add_info(session, entity, self, None, True)
+        # remove record from deleted_entities
+        self.doexec(session, 'DELETE FROM deleted_entities WHERE eid=%s' % eid)
+        self.repo.hm.call_hooks('after_add_entity', session, entity=entity)
+        return errors
+
+    def _undo_r(self, session, action):
+        """undo a relation removal"""
+        errors = []
+        err = errors.append
+        _ = session._
+        subj, rtype, obj = action.eid_from, action.rtype, action.eid_to
+        entities = []
+        for role, eid in (('subject', subj), ('object', obj)):
+            try:
+                entities.append(session.entity_from_eid(eid))
+            except UnknownEid:
+                err(_("Can't restore relation %(rtype)s, %(role)s entity %(eid)s"
+                      " doesn't exist anymore.")
+                    % {'role': _(role),
+                       'rtype': _(rtype),
+                       'eid': eid})
+        if not len(entities) == 2:
+            return errors
+        sentity, oentity = entities
+        try:
+            rschema = self.schema.rschema(rtype)
+            rdef = rschema.rdefs[(sentity.__regid__, oentity.__regid__)]
+        except KeyError:
+            err(_("Can't restore relation %(rtype)s between %(subj)s and "
+                  "%(obj)s, that relation does not exists anymore in the "
+                  "schema.")
+                % {'rtype': rtype,
+                   'subj': subj,
+                   'obj': obj})
+        else:
+            for role, entity in (('subject', sentity),
+                                 ('object', oentity)):
+                try:
+                    _undo_check_relation_target(entity, rdef, role)
+                except UndoException, ex:
+                    err(unicode(ex))
+                    continue
+        if not errors:
+            self.repo.hm.call_hooks('before_add_relation', session,
+                                    eidfrom=subj, rtype=rtype, eidto=obj)
+            # add relation in the database
+            self._add_relation(session, subj, rtype, obj, rschema.inlined)
+            # set related cache
+            session.update_rel_cache_add(subj, rtype, obj, rschema.symmetric)
+            self.repo.hm.call_hooks('after_add_relation', session,
+                                    eidfrom=subj, rtype=rtype, eidto=obj)
+        return errors
+
+    def _undo_c(self, session, action):
+        """undo an entity creation"""
+        return ['undoing of entity creation not yet supported.']
+
+    def _undo_u(self, session, action):
+        """undo an entity update"""
+        return ['undoing of entity updating not yet supported.']
+
+    def _undo_a(self, session, action):
+        """undo a relation addition"""
+        return ['undoing of relation addition not yet supported.']
+
     # full text index handling #################################################
 
     @cached
@@ -650,7 +1019,7 @@
 
 def sql_schema(driver):
     helper = get_db_helper(driver)
-    tstamp_col_type = helper.TYPE_MAPPING['Datetime']
+    typemap = helper.TYPE_MAPPING
     schema = """
 /* Create the repository's system database */
 
@@ -662,10 +1031,10 @@
   source VARCHAR(64) NOT NULL,
   mtime %s NOT NULL,
   extid VARCHAR(256)
-);
-CREATE INDEX entities_type_idx ON entities(type);
-CREATE INDEX entities_mtime_idx ON entities(mtime);
-CREATE INDEX entities_extid_idx ON entities(extid);
+);;
+CREATE INDEX entities_type_idx ON entities(type);;
+CREATE INDEX entities_mtime_idx ON entities(mtime);;
+CREATE INDEX entities_extid_idx ON entities(extid);;
 
 CREATE TABLE deleted_entities (
   eid INTEGER PRIMARY KEY NOT NULL,
@@ -673,11 +1042,58 @@
   source VARCHAR(64) NOT NULL,
   dtime %s NOT NULL,
   extid VARCHAR(256)
-);
-CREATE INDEX deleted_entities_type_idx ON deleted_entities(type);
-CREATE INDEX deleted_entities_dtime_idx ON deleted_entities(dtime);
-CREATE INDEX deleted_entities_extid_idx ON deleted_entities(extid);
-""" % (helper.sql_create_sequence('entities_id_seq'), tstamp_col_type, tstamp_col_type)
+);;
+CREATE INDEX deleted_entities_type_idx ON deleted_entities(type);;
+CREATE INDEX deleted_entities_dtime_idx ON deleted_entities(dtime);;
+CREATE INDEX deleted_entities_extid_idx ON deleted_entities(extid);;
+
+CREATE TABLE transactions (
+  tx_uuid CHAR(32) PRIMARY KEY NOT NULL,
+  tx_user INTEGER NOT NULL,
+  tx_time %s NOT NULL
+);;
+CREATE INDEX transactions_tx_user_idx ON transactions(tx_user);;
+
+CREATE TABLE tx_entity_actions (
+  tx_uuid CHAR(32) REFERENCES transactions(tx_uuid) ON DELETE CASCADE,
+  txa_action CHAR(1) NOT NULL,
+  txa_public %s NOT NULL,
+  txa_order INTEGER,
+  eid INTEGER NOT NULL,
+  etype VARCHAR(64) NOT NULL,
+  changes %s
+);;
+CREATE INDEX tx_entity_actions_txa_action_idx ON tx_entity_actions(txa_action);;
+CREATE INDEX tx_entity_actions_txa_public_idx ON tx_entity_actions(txa_public);;
+CREATE INDEX tx_entity_actions_eid_idx ON tx_entity_actions(eid);;
+CREATE INDEX tx_entity_actions_etype_idx ON tx_entity_actions(etype);;
+
+CREATE TABLE tx_relation_actions (
+  tx_uuid CHAR(32) REFERENCES transactions(tx_uuid) ON DELETE CASCADE,
+  txa_action CHAR(1) NOT NULL,
+  txa_public %s NOT NULL,
+  txa_order INTEGER,
+  eid_from INTEGER NOT NULL,
+  eid_to INTEGER NOT NULL,
+  rtype VARCHAR(256) NOT NULL
+);;
+CREATE INDEX tx_relation_actions_txa_action_idx ON tx_relation_actions(txa_action);;
+CREATE INDEX tx_relation_actions_txa_public_idx ON tx_relation_actions(txa_public);;
+CREATE INDEX tx_relation_actions_eid_from_idx ON tx_relation_actions(eid_from);;
+CREATE INDEX tx_relation_actions_eid_to_idx ON tx_relation_actions(eid_to);;
+""" % (helper.sql_create_sequence('entities_id_seq').replace(';', ';;'),
+       typemap['Datetime'], typemap['Datetime'], typemap['Datetime'],
+       typemap['Boolean'], typemap['Bytes'], typemap['Boolean'])
+    if helper.backend_name == 'sqlite':
+        # sqlite support the ON DELETE CASCADE syntax but do nothing
+        schema += '''
+CREATE TRIGGER fkd_transactions
+BEFORE DELETE ON transactions
+FOR EACH ROW BEGIN
+    DELETE FROM tx_entity_actions WHERE tx_uuid=OLD.tx_uuid;
+    DELETE FROM tx_relation_actions WHERE tx_uuid=OLD.tx_uuid;
+END;;
+'''
     return schema
 
 
@@ -687,18 +1103,19 @@
 %s
 DROP TABLE entities;
 DROP TABLE deleted_entities;
+DROP TABLE transactions;
+DROP TABLE tx_entity_actions;
+DROP TABLE tx_relation_actions;
 """ % helper.sql_drop_sequence('entities_id_seq')
 
 
 def grant_schema(user, set_owner=True):
     result = ''
-    if set_owner:
-        result = 'ALTER TABLE entities OWNER TO %s;\n' % user
-        result += 'ALTER TABLE deleted_entities OWNER TO %s;\n' % user
-        result += 'ALTER TABLE entities_id_seq OWNER TO %s;\n' % user
-    result += 'GRANT ALL ON entities TO %s;\n' % user
-    result += 'GRANT ALL ON deleted_entities TO %s;\n' % user
-    result += 'GRANT ALL ON entities_id_seq TO %s;\n' % user
+    for table in ('entities', 'deleted_entities', 'entities_id_seq',
+                  'transactions', 'tx_entity_actions', 'tx_relation_actions'):
+        if set_owner:
+            result = 'ALTER TABLE %s OWNER TO %s;\n' % (table, user)
+        result += 'GRANT ALL ON %s TO %s;\n' % (table, user)
     return result
 
 
--- a/server/sources/pyrorql.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sources/pyrorql.py	Mon Mar 01 11:26:14 2010 +0100
@@ -203,7 +203,8 @@
                                          insert=False)
                     # entity has been deleted from external repository but is not known here
                     if eid is not None:
-                        repo.delete_info(session, eid)
+                        entity = session.entity_from_eid(eid, etype)
+                        repo.delete_info(session, entity, self.uri, extid)
                 except:
                     self.exception('while updating %s with external id %s of source %s',
                                    etype, extid, self.uri)
@@ -350,11 +351,11 @@
         self._query_cache.clear()
         entity.clear_all_caches()
 
-    def delete_entity(self, session, etype, eid):
+    def delete_entity(self, session, entity):
         """delete an entity from the source"""
         cu = session.pool[self.uri]
-        cu.execute('DELETE %s X WHERE X eid %%(x)s' % etype,
-                   {'x': self.eid2extid(eid, session)}, 'x')
+        cu.execute('DELETE %s X WHERE X eid %%(x)s' % entity.__regid__,
+                   {'x': self.eid2extid(entity.eid, session)}, 'x')
         self._query_cache.clear()
 
     def add_relation(self, session, subject, rtype, object):
--- a/server/sqlutils.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/sqlutils.py	Mon Mar 01 11:26:14 2010 +0100
@@ -94,14 +94,15 @@
     w('')
     dbhelper = db.get_db_helper(driver)
     if text_index:
-        w(dbhelper.sql_init_fti())
+        w(dbhelper.sql_init_fti().replace(';', ';;'))
         w('')
     w(schema2sql(dbhelper, schema, prefix=SQL_PREFIX,
-                 skip_entities=skip_entities, skip_relations=skip_relations))
+                 skip_entities=skip_entities,
+                 skip_relations=skip_relations).replace(';', ';;'))
     if dbhelper.users_support and user:
         w('')
         w(sqlgrants(schema, driver, user, text_index, set_owner,
-                    skip_relations, skip_entities))
+                    skip_relations, skip_entities).replace(';', ';;'))
     return '\n'.join(output)
 
 
--- a/server/test/unittest_repository.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/server/test/unittest_repository.py	Mon Mar 01 11:26:14 2010 +0100
@@ -385,14 +385,14 @@
         entity.eid = -1
         entity.complete = lambda x: None
         self.session.set_pool()
-        self.repo.add_info(self.session, entity, self.repo.sources_by_uri['system'])
+        self.repo.add_info(self.session, entity, self.repo.system_source)
         cu = self.session.system_sql('SELECT * FROM entities WHERE eid = -1')
         data = cu.fetchall()
         self.assertIsInstance(data[0][3], datetime)
         data[0] = list(data[0])
         data[0][3] = None
         self.assertEquals(tuplify(data), [(-1, 'Personne', 'system', None, None)])
-        self.repo.delete_info(self.session, -1)
+        self.repo.delete_info(self.session, entity, 'system', None)
         #self.repo.commit()
         cu = self.session.system_sql('SELECT * FROM entities WHERE eid = -1')
         data = cu.fetchall()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/server/test/unittest_undo.py	Mon Mar 01 11:26:14 2010 +0100
@@ -0,0 +1,206 @@
+"""
+
+:organization: Logilab
+:copyright: 2001-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
+"""
+from __future__ import with_statement
+
+from cubicweb import ValidationError
+from cubicweb.devtools.testlib import CubicWebTC
+from cubicweb.transaction import *
+
+class UndoableTransactionTC(CubicWebTC):
+
+    def setup_database(self):
+        self.session.undo_actions = set('CUDAR')
+        self.toto = self.create_user('toto', password='toto', groups=('users',),
+                                     commit=False)
+        self.txuuid = self.commit()
+
+    def tearDown(self):
+        self.restore_connection()
+        self.session.undo_support = set()
+        super(UndoableTransactionTC, self).tearDown()
+
+    def test_undo_api(self):
+        self.failUnless(self.txuuid)
+        # test transaction api
+        self.assertRaises(NoSuchTransaction,
+                          self.cnx.transaction_info, 'hop')
+        self.assertRaises(NoSuchTransaction,
+                          self.cnx.transaction_actions, 'hop')
+        self.assertRaises(NoSuchTransaction,
+                          self.cnx.undo_transaction, 'hop')
+        txinfo = self.cnx.transaction_info(self.txuuid)
+        self.failUnless(txinfo.datetime)
+        self.assertEquals(txinfo.user_eid, self.session.user.eid)
+        self.assertEquals(txinfo.user().login, 'admin')
+        actions = txinfo.actions_list()
+        self.assertEquals(len(actions), 2)
+        actions = txinfo.actions_list(public=False)
+        self.assertEquals(len(actions), 6)
+        a1 = actions[0]
+        self.assertEquals(a1.action, 'C')
+        self.assertEquals(a1.eid, self.toto.eid)
+        self.assertEquals(a1.etype,'CWUser')
+        self.assertEquals(a1.changes, None)
+        self.assertEquals(a1.public, True)
+        self.assertEquals(a1.order, 1)
+        a4 = actions[3]
+        self.assertEquals(a4.action, 'A')
+        self.assertEquals(a4.rtype, 'in_group')
+        self.assertEquals(a4.eid_from, self.toto.eid)
+        self.assertEquals(a4.eid_to, self.toto.in_group[0].eid)
+        self.assertEquals(a4.order, 4)
+        for i, rtype in ((1, 'owned_by'), (2, 'owned_by'),
+                         (4, 'created_by'), (5, 'in_state')):
+            a = actions[i]
+            self.assertEquals(a.action, 'A')
+            self.assertEquals(a.eid_from, self.toto.eid)
+            self.assertEquals(a.rtype, rtype)
+            self.assertEquals(a.order, i+1)
+        # test undoable_transactions
+        txs = self.cnx.undoable_transactions()
+        self.assertEquals(len(txs), 1)
+        self.assertEquals(txs[0].uuid, self.txuuid)
+        # test transaction_info / undoable_transactions security
+        cnx = self.login('anon')
+        self.assertRaises(NoSuchTransaction,
+                          cnx.transaction_info, self.txuuid)
+        self.assertRaises(NoSuchTransaction,
+                          cnx.transaction_actions, self.txuuid)
+        self.assertRaises(NoSuchTransaction,
+                          cnx.undo_transaction, self.txuuid)
+        txs = cnx.undoable_transactions()
+        self.assertEquals(len(txs), 0)
+
+    def test_undoable_transactions(self):
+        toto = self.toto
+        e = self.session.create_entity('EmailAddress',
+                                       address=u'toto@logilab.org',
+                                       reverse_use_email=toto)
+        txuuid1 = self.commit()
+        toto.delete()
+        txuuid2 = self.commit()
+        undoable_transactions = self.cnx.undoable_transactions
+        txs = undoable_transactions(action='D')
+        self.assertEquals(len(txs), 1, txs)
+        self.assertEquals(txs[0].uuid, txuuid2)
+        txs = undoable_transactions(action='C')
+        self.assertEquals(len(txs), 2, txs)
+        self.assertEquals(txs[0].uuid, txuuid1)
+        self.assertEquals(txs[1].uuid, self.txuuid)
+        txs = undoable_transactions(eid=toto.eid)
+        self.assertEquals(len(txs), 3)
+        self.assertEquals(txs[0].uuid, txuuid2)
+        self.assertEquals(txs[1].uuid, txuuid1)
+        self.assertEquals(txs[2].uuid, self.txuuid)
+        txs = undoable_transactions(etype='CWUser')
+        self.assertEquals(len(txs), 2)
+        txs = undoable_transactions(etype='CWUser', action='C')
+        self.assertEquals(len(txs), 1)
+        self.assertEquals(txs[0].uuid, self.txuuid)
+        txs = undoable_transactions(etype='EmailAddress', action='D')
+        self.assertEquals(len(txs), 0)
+        txs = undoable_transactions(etype='EmailAddress', action='D',
+                                    public=False)
+        self.assertEquals(len(txs), 1)
+        self.assertEquals(txs[0].uuid, txuuid2)
+        txs = undoable_transactions(eid=toto.eid, action='R', public=False)
+        self.assertEquals(len(txs), 1)
+        self.assertEquals(txs[0].uuid, txuuid2)
+
+    def test_undo_deletion_base(self):
+        toto = self.toto
+        e = self.session.create_entity('EmailAddress',
+                                       address=u'toto@logilab.org',
+                                       reverse_use_email=toto)
+        # entity with inlined relation
+        p = self.session.create_entity('CWProperty',
+                                       pkey=u'ui.default-text-format',
+                                       value=u'text/rest',
+                                       for_user=toto)
+        self.commit()
+        txs = self.cnx.undoable_transactions()
+        self.assertEquals(len(txs), 2)
+        toto.delete()
+        txuuid = self.commit()
+        actions = self.cnx.transaction_info(txuuid).actions_list()
+        self.assertEquals(len(actions), 1)
+        toto.clear_all_caches()
+        e.clear_all_caches()
+        errors = self.cnx.undo_transaction(txuuid)
+        undotxuuid = self.commit()
+        self.assertEquals(undotxuuid, None) # undo not undoable
+        self.assertEquals(errors, [])
+        self.failUnless(self.execute('Any X WHERE X eid %(x)s', {'x': toto.eid}, 'x'))
+        self.failUnless(self.execute('Any X WHERE X eid %(x)s', {'x': e.eid}, 'x'))
+        self.failUnless(self.execute('Any X WHERE X has_text "toto@logilab"'))
+        self.assertEquals(toto.state, 'activated')
+        self.assertEquals(toto.get_email(), 'toto@logilab.org')
+        self.assertEquals([(p.pkey, p.value) for p in toto.reverse_for_user],
+                          [('ui.default-text-format', 'text/rest')])
+        self.assertEquals([g.name for g in toto.in_group],
+                          ['users'])
+        self.assertEquals([et.name for et in toto.related('is', entities=True)],
+                          ['CWUser'])
+        self.assertEquals([et.name for et in toto.is_instance_of],
+                          ['CWUser'])
+        # undoing shouldn't be visble in undoable transaction, and the undoed
+        # transaction should be removed
+        txs = self.cnx.undoable_transactions()
+        self.assertEquals(len(txs), 2)
+        self.assertRaises(NoSuchTransaction,
+                          self.cnx.transaction_info, txuuid)
+        # also check transaction actions have been properly deleted
+        cu = self.session.system_sql(
+            "SELECT * from tx_entity_actions WHERE tx_uuid='%s'" % txuuid)
+        self.failIf(cu.fetchall())
+        cu = self.session.system_sql(
+            "SELECT * from tx_relation_actions WHERE tx_uuid='%s'" % txuuid)
+        self.failIf(cu.fetchall())
+        # the final test: check we can login with the previously deleted user
+        self.login('toto')
+
+    def test_undo_deletion_integrity_1(self):
+        session = self.session
+        # 'Personne fiche Card with' '??' cardinality
+        c = session.create_entity('Card', title=u'hop', content=u'hop')
+        p = session.create_entity('Personne', nom=u'louis', fiche=c)
+        self.commit()
+        c.delete()
+        txuuid = self.commit()
+        c2 = session.create_entity('Card', title=u'hip', content=u'hip')
+        p.set_relations(fiche=c2)
+        self.commit()
+        errors = self.cnx.undo_transaction(txuuid)
+        self.commit()
+        p.clear_all_caches()
+        self.assertEquals(p.fiche[0].eid, c2.eid)
+        self.assertEquals(len(errors), 1)
+        self.assertEquals(errors[0],
+                          "Can't restore object relation fiche to entity "
+                          "%s which is already linked using this relation." % p.eid)
+
+    def test_undo_deletion_integrity_2(self):
+        # test validation error raised if we can't restore a required relation
+        session = self.session
+        g = session.create_entity('CWGroup', name=u'staff')
+        session.execute('DELETE U in_group G WHERE U eid %(x)s', {'x': self.toto.eid})
+        self.toto.set_relations(in_group=g)
+        self.commit()
+        self.toto.delete()
+        txuuid = self.commit()
+        g.delete()
+        self.commit()
+        errors = self.cnx.undo_transaction(txuuid)
+        self.assertRaises(ValidationError, self.commit)
+
+    def test_undo_creation(self):
+        # XXX what about relation / composite entities which have been created
+        # afterwhile and linked to the undoed addition ?
+        self.skip('not implemented')
+
+    # test implicit 'replacement' of an inlined relation
--- a/test/unittest_dbapi.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/test/unittest_dbapi.py	Mon Mar 01 11:26:14 2010 +0100
@@ -5,11 +5,13 @@
 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
 :license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
 """
+from __future__ import with_statement
+from copy import copy
+
 from cubicweb import ConnectionError
 from cubicweb.dbapi import ProgrammingError
 from cubicweb.devtools.testlib import CubicWebTC
 
-
 class DBAPITC(CubicWebTC):
 
     def test_public_repo_api(self):
@@ -68,6 +70,7 @@
         self.assertRaises(ProgrammingError, cnx.set_shared_data, 'data', 0)
         self.assertRaises(ProgrammingError, cnx.get_shared_data, 'data')
 
+
 if __name__ == '__main__':
     from logilab.common.testlib import unittest_main
     unittest_main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/transaction.py	Mon Mar 01 11:26:14 2010 +0100
@@ -0,0 +1,96 @@
+"""undoable transaction objects.
+
+
+This module is in the cubicweb package and not in cubicweb.server because those
+objects should be accessible to client through pyro, where the cubicweb.server
+package may not be installed.
+
+:organization: Logilab
+:copyright: 2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
+"""
+__docformat__ = "restructuredtext en"
+_ = unicode
+
+from cubicweb import RepositoryError
+
+
+ACTION_LABELS = {
+    'C': _('entity creation'),
+    'U': _('entity update'),
+    'D': _('entity deletion'),
+    'A': _('relation add'),
+    'R': _('relation removal'),
+    }
+
+
+class NoSuchTransaction(RepositoryError):
+    pass
+
+
+class Transaction(object):
+    """an undoable transaction"""
+
+    def __init__(self, uuid, time, ueid):
+        self.uuid = uuid
+        self.datetime = time
+        self.user_eid = ueid
+        # should be set by the dbapi connection
+        self.req = None
+
+    def __repr__(self):
+        return '<Transaction %s by %s on %s>' % (
+            self.uuid, self.user_eid, self.datetime)
+
+    def user(self):
+        """return the user entity which has done the transaction,
+        none if not found.
+        """
+        return self.req.execute('Any X WHERE X eid %(x)s',
+                                {'x': self.user_eid}, 'x').get_entity(0, 0)
+
+    def actions_list(self, public=True):
+        """return an ordered list of action effectued during that transaction
+
+        if public is true, return only 'public' action, eg not ones triggered
+        under the cover by hooks.
+        """
+        return self.req.cnx.transaction_actions(self.uuid, public)
+
+
+class AbstractAction(object):
+    def __init__(self, action, public, order):
+        self.action = action
+        self.public = public
+        self.order = order
+
+    @property
+    def label(self):
+        return ACTION_LABELS[self.action]
+
+
+class EntityAction(AbstractAction):
+    def __init__(self, action, public, order, etype, eid, changes):
+        AbstractAction.__init__(self, action, public, order)
+        self.etype = etype
+        self.eid = eid
+        self.changes = changes
+
+    def __repr__(self):
+        return '<%s: %s %s (%s)>' % (
+            self.label, self.eid, self.changes,
+            self.public and 'dbapi' or 'hook')
+
+
+class RelationAction(AbstractAction):
+    def __init__(self, action, public, order, rtype, eidfrom, eidto):
+        AbstractAction.__init__(self, action, public, order)
+        self.rtype = rtype
+        self.eid_from = eidfrom
+        self.eid_to = eidto
+
+    def __repr__(self):
+        return '<%s: %s %s %s (%s)>' % (
+            self.label, self.eid_from, self.rtype, self.eid_to,
+            self.public and 'dbapi' or 'hook')
--- a/web/application.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/web/application.py	Mon Mar 01 11:26:14 2010 +0100
@@ -342,7 +342,11 @@
                 # redirect is raised by edit controller when everything went fine,
                 # so try to commit
                 try:
-                    req.cnx.commit()
+                    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)
                 except ValidationError, ex:
                     self.validation_error_handler(req, ex)
                 except Unauthorized, ex:
--- a/web/views/basecontrollers.py	Wed Mar 10 16:07:24 2010 +0100
+++ b/web/views/basecontrollers.py	Mon Mar 01 11:26:14 2010 +0100
@@ -605,3 +605,27 @@
         url = self._cw.build_url(__message=self._cw._('bug report sent'))
         raise Redirect(url)
 
+
+class UndoController(SendMailController):
+    __regid__ = 'undo'
+    __select__ = authenticated_user() & match_form_params('txuuid')
+
+    def publish(self, rset=None):
+        txuuid = self._cw.form['txuuid']
+        errors = self._cw.cnx.undo_transaction(txuuid)
+        if errors:
+            self.w(self._cw._('some errors occured:'))
+            self.wview('pyvalist', pyvalue=errors)
+        else:
+            self.redirect()
+
+    def redirect(self):
+        req = self._cw
+        breadcrumbs = req.get_session_data('breadcrumbs', None)
+        if breadcrumbs is not None and len(breadcrumbs) > 1:
+            url = req.rebuild_url(breadcrumbs[-2],
+                                  __message=req._('transaction undoed'))
+        else:
+            url = req.build_url(__message=req._('transaction undoed'))
+        raise Redirect(url)
+