Merge with 3.22 branch
authorDenis Laxalde <denis.laxalde@logilab.fr>
Wed, 17 Feb 2016 13:45:34 +0100
changeset 11129 97095348b3ee
parent 11128 9b4de34ad394 (current diff)
parent 11127 6464edfa95bb (diff)
child 11130 9b8dcde03499
Merge with 3.22 branch The merge was clean, just dropped cubicweb/web/data/cubicweb.goa.js.
cubicweb/__init__.py
cubicweb/__pkginfo__.py
cubicweb/_exceptions.py
cubicweb/cwconfig.py
cubicweb/cwctl.py
cubicweb/dataimport/pgstore.py
cubicweb/dataimport/test/unittest_importer.py
cubicweb/devtools/__init__.py
cubicweb/devtools/devctl.py
cubicweb/devtools/fill.py
cubicweb/devtools/testlib.py
cubicweb/entities/adapters.py
cubicweb/entity.py
cubicweb/hooks/notification.py
cubicweb/hooks/syncschema.py
cubicweb/hooks/syncsession.py
cubicweb/hooks/test/data/hooks.py
cubicweb/hooks/test/unittest_notification.py
cubicweb/hooks/test/unittest_syncsession.py
cubicweb/misc/migration/3.21.0_Any.py
cubicweb/misc/migration/3.22.0_Any.py
cubicweb/misc/migration/3.22.1_Any.py
cubicweb/misc/migration/postcreate.py
cubicweb/predicates.py
cubicweb/server/__init__.py
cubicweb/server/repository.py
cubicweb/server/sources/datafeed.py
cubicweb/server/sources/native.py
cubicweb/server/test/data-migractions/migratedapp/schema.py
cubicweb/server/test/unittest_migractions.py
cubicweb/server/utils.py
cubicweb/skeleton/DISTNAME.spec.tmpl
cubicweb/sobjects/test/unittest_notification.py
cubicweb/test/unittest_mail.py
cubicweb/web/component.py
cubicweb/web/data/cubicweb.goa.js
cubicweb/web/formfields.py
cubicweb/web/test/data/schema.py
cubicweb/web/test/unittest_application.py
cubicweb/web/test/unittest_views_editforms.py
cubicweb/web/views/autoform.py
cubicweb/web/views/debug.py
cubicweb/web/views/editcontroller.py
debian/control
--- a/.hgtags	Thu Feb 11 21:59:49 2016 +0100
+++ b/.hgtags	Wed Feb 17 13:45:34 2016 +0100
@@ -472,6 +472,9 @@
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 3.19.13
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 debian/3.19.13-1
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 centos/3.19.13-1
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 3.19.14
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 debian/3.19.14-1
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 centos/3.19.14-1
 7e6b7739afe6128589ad51b0318decb767cbae36 3.20.0
 7e6b7739afe6128589ad51b0318decb767cbae36 debian/3.20.0-1
 7e6b7739afe6128589ad51b0318decb767cbae36 centos/3.20.0-1
@@ -511,6 +514,9 @@
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 3.20.12
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 debian/3.20.12-1
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 centos/3.20.12-1
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a 3.20.13
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a debian/3.20.13-1
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a centos/3.20.13-1
 887c6eef807781560adcd4ecd2dea9011f5a6681 3.21.0
 887c6eef807781560adcd4ecd2dea9011f5a6681 debian/3.21.0-1
 887c6eef807781560adcd4ecd2dea9011f5a6681 centos/3.21.0-1
@@ -530,6 +536,12 @@
 e0572a786e6b4b0965d405dd95cf5bce754005a2 debian/3.21.5-1
 e0572a786e6b4b0965d405dd95cf5bce754005a2 centos/3.21.5-1
 228b6d2777e44d7bc158d0b4579d09960acea926 debian/3.21.5-2
+b3cbbb7690b6e193570ffe4846615d372868a923 3.21.6
+b3cbbb7690b6e193570ffe4846615d372868a923 debian/3.21.6-1
+b3cbbb7690b6e193570ffe4846615d372868a923 centos/3.21.6-1
 de472896fc0a18d6b831e6fed0eeda5921ec522c 3.22.0
 de472896fc0a18d6b831e6fed0eeda5921ec522c debian/3.22.0-1
 de472896fc0a18d6b831e6fed0eeda5921ec522c centos/3.22.0-1
+d0d86803a804854be0a1b2d49079a94d1c193ee9 3.22.1
+d0d86803a804854be0a1b2d49079a94d1c193ee9 debian/3.22.1-1
+d0d86803a804854be0a1b2d49079a94d1c193ee9 centos/3.22.1-1
--- a/cubicweb.spec	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb.spec	Wed Feb 17 13:45:34 2016 +0100
@@ -5,9 +5,10 @@
 %define python python
 %define __python /usr/bin/python
 %endif
+%{!?python_sitelib: %define python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print get_python_lib()")}
 
 Name:           cubicweb
-Version:        3.22.0
+Version:        3.22.1
 Release:        logilab.1%{?dist}
 Summary:        CubicWeb is a semantic web application framework
 Source0:        http://download.logilab.org/pub/cubicweb/cubicweb-%{version}.tar.gz
@@ -30,7 +31,7 @@
 Requires:       %{python}-lxml
 Requires:       %{python}-twisted-web
 Requires:       %{python}-markdown
-Requires:       %{python}-tz
+Requires:       pytz
 # the schema view uses `dot'; at least on el5, png output requires graphviz-gd
 Requires:       graphviz-gd
 Requires:       gettext
@@ -57,5 +58,6 @@
 %files 
 %defattr(-, root, root)
 %dir /var/log/cubicweb
-/*
-
+%{_prefix}/share/cubicweb
+%{python_sitelib}
+%{_bindir}
--- a/cubicweb/__init__.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/__init__.py	Wed Feb 17 13:45:34 2016 +0100
@@ -34,7 +34,11 @@
 CW_SOFTWARE_ROOT = __path__[0]
 
 import sys, os, logging
-from io import BytesIO
+if (2, 7) <= sys.version_info < (2, 7, 4):
+    # http://bugs.python.org/issue10211
+    from StringIO import StringIO as BytesIO
+else:
+    from io import BytesIO
 
 from six.moves import cPickle as pickle
 
@@ -79,12 +83,14 @@
     def __init__(self, buf=b''):
         assert isinstance(buf, self._allowed_types), \
                "Binary objects must use bytes/buffer objects, not %s" % buf.__class__
-        super(Binary, self).__init__(buf)
+        # don't call super, BytesIO may be an old-style class (on python < 2.7.4)
+        BytesIO.__init__(self, buf)
 
     def write(self, data):
         assert isinstance(data, self._allowed_types), \
                "Binary objects must use bytes/buffer objects, not %s" % data.__class__
-        super(Binary, self).write(data)
+        # don't call super, BytesIO may be an old-style class (on python < 2.7.4)
+        BytesIO.write(self, data)
 
     def to_file(self, fobj):
         """write a binary to disk
--- a/cubicweb/__pkginfo__.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/__pkginfo__.py	Wed Feb 17 13:45:34 2016 +0100
@@ -22,7 +22,7 @@
 
 modname = distname = "cubicweb"
 
-numversion = (3, 22, 0)
+numversion = (3, 22, 1)
 version = '.'.join(str(num) for num in numversion)
 
 description = "a repository of entities / relations for knowledge management"
--- a/cubicweb/_exceptions.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/_exceptions.py	Wed Feb 17 13:45:34 2016 +0100
@@ -117,19 +117,19 @@
     """raised when a user tries to perform an action without sufficient
     credentials
     """
-    msg = 'You are not allowed to perform this operation'
-    msg1 = 'You are not allowed to perform %s operation on %s'
+    msg = u'You are not allowed to perform this operation'
+    msg1 = u'You are not allowed to perform %s operation on %s'
     var = None
 
-    def __str__(self):
+    def __unicode__(self):
         try:
             if self.args and len(self.args) == 2:
                 return self.msg1 % self.args
             if self.args:
-                return ' '.join(self.args)
+                return u' '.join(self.args)
             return self.msg
         except Exception as ex:
-            return str(ex)
+            return text_type(ex)
 
 class Forbidden(SecurityError):
     """raised when a user tries to perform a forbidden action
--- a/cubicweb/cwconfig.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/cwconfig.py	Wed Feb 17 13:45:34 2016 +0100
@@ -628,7 +628,7 @@
         cls.cls_adjust_sys_path()
         for ctlfile in ('web/webctl.py',  'etwist/twctl.py',
                         'server/serverctl.py',
-                        'devtools/devctl.py', 'goa/goactl.py'):
+                        'devtools/devctl.py',):
             if exists(join(CW_SOFTWARE_ROOT, ctlfile)):
                 try:
                     load_module_from_file(join(CW_SOFTWARE_ROOT, ctlfile))
--- a/cubicweb/cwctl.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/cwctl.py	Wed Feb 17 13:45:34 2016 +0100
@@ -43,6 +43,7 @@
 from logilab.common.clcommands import CommandLine
 from logilab.common.shellutils import ASK
 from logilab.common.configuration import merge_options
+from logilab.common.deprecation import deprecated
 
 from cubicweb import ConfigurationError, ExecutionError, BadCommandUsage
 from cubicweb.utils import support_args
@@ -103,38 +104,19 @@
         )
     actionverb = None
 
+    @deprecated('[3.22] startorder is not used any more')
     def ordered_instances(self):
-        """return instances in the order in which they should be started,
-        considering $REGISTRY_DIR/startorder file if it exists (useful when
-        some instances depends on another as external source).
-
-        Instance used by another one should appears first in the file (one
-        instance per line)
+        """return list of known instances
         """
         regdir = cwcfg.instances_dir()
-        _allinstances = list_instances(regdir)
-        if isfile(join(regdir, 'startorder')):
-            allinstances = []
-            for line in open(join(regdir, 'startorder')):
-                line = line.strip()
-                if line and not line.startswith('#'):
-                    try:
-                        _allinstances.remove(line)
-                        allinstances.append(line)
-                    except ValueError:
-                        print('ERROR: startorder file contains unexistant '
-                              'instance %s' % line)
-            allinstances += _allinstances
-        else:
-            allinstances = _allinstances
-        return allinstances
+        return list_instances(regdir)
 
     def run(self, args):
         """run the <command>_method on each argument (a list of instance
         identifiers)
         """
         if not args:
-            args = self.ordered_instances()
+            args = list_instances(cwcfg.instances_dir())
             try:
                 askconfirm = not self.config.force
             except AttributeError:
@@ -572,11 +554,6 @@
     name = 'stop'
     actionverb = 'stopped'
 
-    def ordered_instances(self):
-        instances = super(StopInstanceCommand, self).ordered_instances()
-        instances.reverse()
-        return instances
-
     def stop_instance(self, appid):
         """stop the instance's server"""
         config = cwcfg.config_for(appid)
@@ -621,29 +598,6 @@
     name = 'restart'
     actionverb = 'restarted'
 
-    def run_args(self, args, askconfirm):
-        regdir = cwcfg.instances_dir()
-        if not isfile(join(regdir, 'startorder')) or len(args) <= 1:
-            # no specific startorder
-            super(RestartInstanceCommand, self).run_args(args, askconfirm)
-            return
-        print ('some specific start order is specified, will first stop all '
-               'instances then restart them.')
-        # get instances in startorder
-        for appid in args:
-            if askconfirm:
-                print('*'*72)
-                if not ASK.confirm('%s instance %r ?' % (self.name, appid)):
-                    continue
-            StopInstanceCommand(self.logger).stop_instance(appid)
-        forkcmd = [w for w in sys.argv if not w in args]
-        forkcmd[1] = 'start'
-        forkcmd = ' '.join(forkcmd)
-        for appid in reversed(args):
-            status = system('%s %s' % (forkcmd, appid))
-            if status:
-                sys.exit(status)
-
     def restart_instance(self, appid):
         StopInstanceCommand(self.logger).stop_instance(appid)
         self.start_instance(appid)
--- a/cubicweb/dataimport/pgstore.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/dataimport/pgstore.py	Wed Feb 17 13:45:34 2016 +0100
@@ -30,7 +30,6 @@
 from six.moves import cPickle as pickle, range
 
 from cubicweb.utils import make_uid
-from cubicweb.server.utils import eschema_eid
 from cubicweb.server.sqlutils import SQL_PREFIX
 from cubicweb.dataimport.stores import NoHookRQLObjectStore
 
@@ -425,20 +424,14 @@
                  'asource': source.uri}
         self._handle_insert_entity_sql(cnx, self.sqlgen.insert('entities', attrs), attrs)
         # insert core relations: is, is_instance_of and cw_source
-        try:
-            self._handle_is_relation_sql(cnx, 'INSERT INTO is_relation(eid_from,eid_to) VALUES (%s,%s)',
-                                         (entity.eid, eschema_eid(cnx, entity.e_schema)))
-        except IndexError:
-            # during schema serialization, skip
-            pass
-        else:
-            for eschema in entity.e_schema.ancestors() + [entity.e_schema]:
-                self._handle_is_relation_sql(cnx,
-                                             'INSERT INTO is_instance_of_relation(eid_from,eid_to) VALUES (%s,%s)',
-                                             (entity.eid, eschema_eid(cnx, eschema)))
-        if 'CWSource' in self.schema and source.eid is not None: # else, cw < 3.10
-            self._handle_is_relation_sql(cnx, 'INSERT INTO cw_source_relation(eid_from,eid_to) VALUES (%s,%s)',
-                                         (entity.eid, source.eid))
+        self._handle_is_relation_sql(cnx, 'INSERT INTO is_relation(eid_from,eid_to) VALUES (%s,%s)',
+                                     (entity.eid, entity.e_schema.eid))
+        for eschema in entity.e_schema.ancestors() + [entity.e_schema]:
+            self._handle_is_relation_sql(cnx,
+                                         'INSERT INTO is_instance_of_relation(eid_from,eid_to) VALUES (%s,%s)',
+                                         (entity.eid, eschema.eid))
+        self._handle_is_relation_sql(cnx, 'INSERT INTO cw_source_relation(eid_from,eid_to) VALUES (%s,%s)',
+                                     (entity.eid, source.eid))
         # now we can update the full text index
         if self.do_fti and self.need_fti_indexation(entity.cw_etype):
             self.index_entity(cnx, entity=entity)
--- a/cubicweb/dataimport/test/unittest_importer.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/dataimport/test/unittest_importer.py	Wed Feb 17 13:45:34 2016 +0100
@@ -132,7 +132,7 @@
             importer = self.importer(cnx)
             # First import
             richelieu = ExtEntity('Personne', 11,
-                                  {'nom': {u'Richelieu Diacre'}})
+                                  {'nom': set([u'Richelieu Diacre'])})
             importer.import_entities([richelieu])
             cnx.commit()
             rset = cnx.execute('Any X WHERE X is Personne')
@@ -140,7 +140,7 @@
             self.assertEqual(entity.nom, u'Richelieu Diacre')
             # Second import
             richelieu = ExtEntity('Personne', 11,
-                                  {'nom': {u'Richelieu Cardinal'}})
+                                  {'nom': set([u'Richelieu Cardinal'])})
             importer.import_entities([richelieu])
             cnx.commit()
             rset = cnx.execute('Any X WHERE X is Personne')
--- a/cubicweb/devtools/__init__.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/devtools/__init__.py	Wed Feb 17 13:45:34 2016 +0100
@@ -405,7 +405,7 @@
         """Factory method to create a new Repository Instance"""
         config._cubes = None
         repo = config.repository()
-        config.repository = lambda x=None: repo
+        config.repository = lambda vreg=None: repo
         # extending Repository class
         repo._has_started = False
         repo._needs_refresh = False
--- a/cubicweb/devtools/devctl.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/devtools/devctl.py	Wed Feb 17 13:45:34 2016 +0100
@@ -44,6 +44,11 @@
 from cubicweb.server.serverconfig import ServerConfiguration
 
 
+STD_BLACKLIST = set(STD_BLACKLIST)
+STD_BLACKLIST.add('.tox')
+STD_BLACKLIST.add('test')
+
+
 class DevConfiguration(ServerConfiguration, WebConfiguration):
     """dummy config to get full library schema and appobjects for
     a cube or for cubicweb (without a home)
@@ -453,7 +458,7 @@
     schemapotstream.close()
     print('TAL', end=' ')
     tali18nfile = osp.join(tempdir, 'tali18n.py')
-    ptfiles = find('.', ('.py', '.pt'), blacklist=STD_BLACKLIST+('test',))
+    ptfiles = find('.', ('.py', '.pt'), blacklist=STD_BLACKLIST)
     extract_from_tal(ptfiles, tali18nfile)
     print('Javascript')
     jsfiles =  [jsfile for jsfile in find('.', '.js')
@@ -468,7 +473,7 @@
             potfiles.append(tmppotfile)
     print('-> creating cube-specific catalog')
     tmppotfile = osp.join(tempdir, 'generated.pot')
-    cubefiles = find('.', '.py', blacklist=STD_BLACKLIST+('test',))
+    cubefiles = find('.', '.py', blacklist=STD_BLACKLIST)
     cubefiles.append(tali18nfile)
     cmd = ['xgettext', '--no-location', '--omit-header', '-k_', '-o', tmppotfile]
     cmd.extend(cubefiles)
--- a/cubicweb/devtools/fill.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/devtools/fill.py	Wed Feb 17 13:45:34 2016 +0100
@@ -42,6 +42,9 @@
 from cubicweb.schema import RQLConstraint
 
 def custom_range(start, stop, step):
+    if start == stop:
+        yield start
+        return
     while start < stop:
         yield start
         start += step
@@ -214,8 +217,10 @@
         minvalue = maxvalue = None
         for cst in self.eschema.rdef(attrname).constraints:
             if isinstance(cst, IntervalBoundConstraint):
-                minvalue = self._actual_boundary(entity, attrname, cst.minvalue)
-                maxvalue = self._actual_boundary(entity, attrname, cst.maxvalue)
+                if cst.minvalue is not None:
+                    minvalue = self._actual_boundary(entity, attrname, cst.minvalue)
+                if cst.maxvalue is not None:
+                    maxvalue = self._actual_boundary(entity, attrname, cst.maxvalue)
             elif isinstance(cst, BoundaryConstraint):
                 if cst.operator[0] == '<':
                     maxvalue = self._actual_boundary(entity, attrname, cst.boundary)
--- a/cubicweb/devtools/testlib.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/devtools/testlib.py	Wed Feb 17 13:45:34 2016 +0100
@@ -318,7 +318,6 @@
         """return admin session"""
         return self._admin_session
 
-    # XXX this doesn't need to a be classmethod anymore
     def _init_repo(self):
         """init the repository and connection to it.
         """
--- a/cubicweb/entities/adapters.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/entities/adapters.py	Wed Feb 17 13:45:34 2016 +0100
@@ -423,5 +423,9 @@
         else:
             assert 0
         key = rschema.type + '-subject'
-        msg, args = constraint.failed_message(key, self.entity.cw_edited[rschema.type])
+        # use .get since a constraint may be associated to an attribute that isn't edited (e.g.
+        # constraint between two attributes). This should be the purpose of an api rework at some
+        # point, we currently rely on the fact that such constraint will provide a dedicated user
+        # message not relying on the `value` argument
+        msg, args = constraint.failed_message(key, self.entity.cw_edited.get(rschema.type))
         raise ValidationError(self.entity.eid, {key: msg}, args)
--- a/cubicweb/entity.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/entity.py	Wed Feb 17 13:45:34 2016 +0100
@@ -20,7 +20,6 @@
 __docformat__ = "restructuredtext en"
 
 from warnings import warn
-from functools import partial
 
 from six import text_type, string_types, integer_types
 from six.moves import range
--- a/cubicweb/hooks/notification.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/hooks/notification.py	Wed Feb 17 13:45:34 2016 +0100
@@ -167,7 +167,7 @@
     __abstract__ = True # do not register by default
     __select__ = NotificationHook.__select__ & hook.issued_from_user_query()
     events = ('before_update_entity',)
-    skip_attrs = set()
+    skip_attrs = set(['modification_date'])
 
     def __call__(self):
         cnx = self._cw
--- a/cubicweb/hooks/syncschema.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/hooks/syncschema.py	Wed Feb 17 13:45:34 2016 +0100
@@ -606,21 +606,22 @@
         # relations, but only if it's the last instance for this relation type
         # for other relations
         if (rschema.final or rschema.inlined):
-            rset = execute('Any COUNT(X) WHERE X is %s, X relation_type R, '
-                           'R eid %%(r)s, X from_entity E, E eid %%(e)s'
-                           % rdeftype,
-                           {'r': rschema.eid, 'e': rdef.subject.eid})
-            if rset[0][0] == 0 and not cnx.deleted_in_transaction(rdef.subject.eid):
-                ptypes = cnx.transaction_data.setdefault('pendingrtypes', set())
-                ptypes.add(rschema.type)
-                DropColumn.get_instance(cnx).add_data((str(rdef.subject), str(rschema)))
-            elif rschema.inlined:
-                cnx.system_sql('UPDATE %s%s SET %s%s=NULL WHERE '
-                               'EXISTS(SELECT 1 FROM entities '
-                               '       WHERE eid=%s%s AND type=%%(to_etype)s)'
-                               % (SQL_PREFIX, rdef.subject, SQL_PREFIX, rdef.rtype,
-                                  SQL_PREFIX, rdef.rtype),
-                               {'to_etype': rdef.object.type})
+            if not cnx.deleted_in_transaction(rdef.subject.eid):
+                rset = execute('Any COUNT(X) WHERE X is %s, X relation_type R, '
+                               'R eid %%(r)s, X from_entity E, E eid %%(e)s'
+                               % rdeftype,
+                               {'r': rschema.eid, 'e': rdef.subject.eid})
+                if rset[0][0] == 0:
+                    ptypes = cnx.transaction_data.setdefault('pendingrtypes', set())
+                    ptypes.add(rschema.type)
+                    DropColumn.get_instance(cnx).add_data((str(rdef.subject), str(rschema)))
+                elif rschema.inlined:
+                    cnx.system_sql('UPDATE %s%s SET %s%s=NULL WHERE '
+                                   'EXISTS(SELECT 1 FROM entities '
+                                   '       WHERE eid=%s%s AND type=%%(to_etype)s)'
+                                   % (SQL_PREFIX, rdef.subject, SQL_PREFIX, rdef.rtype,
+                                      SQL_PREFIX, rdef.rtype),
+                                   {'to_etype': rdef.object.type})
         elif lastrel:
             DropRelationTable(cnx, str(rschema))
         else:
--- a/cubicweb/hooks/syncsession.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/hooks/syncsession.py	Wed Feb 17 13:45:34 2016 +0100
@@ -148,7 +148,8 @@
         """the observed connections set has been commited"""
         cwprop = self.cwprop
         if not cwprop.for_user:
-            self.cnx.vreg['propertyvalues'][cwprop.pkey] = cwprop.value
+            self.cnx.vreg['propertyvalues'][cwprop.pkey] = \
+                self.cnx.vreg.typed_value(cwprop.pkey, cwprop.value)
         # if for_user is set, update is handled by a ChangeCWPropertyOp operation
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/hooks/test/data/hooks.py	Wed Feb 17 13:45:34 2016 +0100
@@ -0,0 +1,8 @@
+from cubicweb.predicates import is_instance
+from cubicweb.hooks import notification
+
+
+class FolderUpdateHook(notification.EntityUpdateHook):
+    __select__ = (notification.EntityUpdateHook.__select__ &
+                  is_instance('Folder'))
+    order = 100  # late trigger so that metadata hooks come before.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/hooks/test/unittest_notification.py	Wed Feb 17 13:45:34 2016 +0100
@@ -0,0 +1,39 @@
+# copyright 2015 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/>.
+"""tests for notification hooks"""
+
+from cubicweb.devtools.testlib import CubicWebTC
+
+
+class NotificationHooksTC(CubicWebTC):
+
+    def test_entity_update(self):
+        """Check transaction_data['changes'] filled by "notifentityupdated" hook.
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            root = cnx.create_entity('Folder', name=u'a')
+            cnx.commit()
+            root.cw_set(name=u'b')
+            self.assertIn('changes', cnx.transaction_data)
+            self.assertEqual(cnx.transaction_data['changes'],
+                             {root.eid: set([('name', u'a', u'b')])})
+
+
+if __name__ == '__main__':
+    from logilab.common.testlib import unittest_main
+    unittest_main()
--- a/cubicweb/hooks/test/unittest_syncsession.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/hooks/test/unittest_syncsession.py	Wed Feb 17 13:45:34 2016 +0100
@@ -71,6 +71,15 @@
                 req.execute('INSERT CWProperty X: X pkey "ui.language", X value "hop"')
             self.assertEqual(cm.exception.errors, {'value-subject': u'unauthorized value'})
 
+    def test_vreg_propertyvalues_update(self):
+        self.vreg.register_property(
+            'test.int', type='Int', help='', sitewide=True)
+        with self.admin_access.repo_cnx() as cnx:
+            cnx.execute('INSERT CWProperty X: X pkey "test.int", X value "42"')
+            cnx.commit()
+        self.assertEqual(self.vreg.property_value('test.int'), 42)
+
+
 if __name__ == '__main__':
     from logilab.common.testlib import unittest_main
     unittest_main()
--- a/cubicweb/misc/migration/3.21.0_Any.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/misc/migration/3.21.0_Any.py	Wed Feb 17 13:45:34 2016 +0100
@@ -166,9 +166,9 @@
             cstr, helper, prefix='cw_')
     args = {'e': rdef.subject.type, 'c': cstrname, 'v': check}
     if repo.system_source.dbdriver == 'postgres':
-        sql('ALTER TABLE cw_%(e)s DROP CONSTRAINT IF EXISTS %(c)s' % args)
+        sql('ALTER TABLE cw_%(e)s DROP CONSTRAINT IF EXISTS %(c)s' % args, ask_confirm=False)
     elif repo.system_source.dbdriver.startswith('sqlserver'):
         sql("IF OBJECT_ID('%(c)s', 'C') IS NOT NULL "
-            "ALTER TABLE cw_%(e)s DROP CONSTRAINT %(c)s" % args)
-    sql('ALTER TABLE cw_%(e)s ADD CONSTRAINT %(c)s CHECK(%(v)s)' % args)
+            "ALTER TABLE cw_%(e)s DROP CONSTRAINT %(c)s" % args, ask_confirm=False)
+    sql('ALTER TABLE cw_%(e)s ADD CONSTRAINT %(c)s CHECK(%(v)s)' % args, ask_confirm=False)
 commit()
--- a/cubicweb/misc/migration/3.22.0_Any.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/misc/migration/3.22.0_Any.py	Wed Feb 17 13:45:34 2016 +0100
@@ -12,7 +12,7 @@
     sql("SET TIME ZONE '%s'" % timezone)
 
 for entity in schema.entities():
-    if entity.final:
+    if entity.final or entity.type not in fsschema:
         continue
     change_attribute_type(entity.type, 'creation_date', 'TZDatetime', ask_confirm=False)
     change_attribute_type(entity.type, 'modification_date', 'TZDatetime', ask_confirm=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/misc/migration/3.22.1_Any.py	Wed Feb 17 13:45:34 2016 +0100
@@ -0,0 +1,12 @@
+from os import unlink
+from os.path import isfile, join
+from cubicweb.cwconfig import CubicWebConfiguration as cwcfg
+
+regdir = cwcfg.instances_dir()
+
+if isfile(join(regdir, 'startorder')):
+    if confirm('The startorder file is not used anymore in Cubicweb 3.22. '
+               'Should I delete it?',
+               shell=False, pdb=False):
+        unlink(join(regdir, 'startorder'))
+
--- a/cubicweb/misc/migration/postcreate.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/misc/migration/postcreate.py	Wed Feb 17 13:45:34 2016 +0100
@@ -30,7 +30,7 @@
     create_entity('CWProperty', pkey=u'system.version.%s' % cube.lower(),
                   value=text_type(config.cube_version(cube)))
 
-# some entities have been added before schema entities, fix the 'is' and
+# some entities have been added before schema entities, add their missing 'is' and
 # 'is_instance_of' relations
 for rtype in ('is', 'is_instance_of'):
     sql('INSERT INTO %s_relation '
--- a/cubicweb/predicates.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/predicates.py	Wed Feb 17 13:45:34 2016 +0100
@@ -498,7 +498,8 @@
                 except ValueError:
                     page_size = None
             if page_size is None:
-                page_size = req.property_value('navigation.page-size')
+                page_size_prop = getattr(cls, 'page_size_property', 'navigation.page-size')
+                page_size = req.property_value(page_size_prop)
         if len(rset) <= (page_size*self.nbpages):
             return 0
         return self.nbpages
--- a/cubicweb/server/__init__.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/__init__.py	Wed Feb 17 13:45:34 2016 +0100
@@ -297,6 +297,9 @@
     # re-login using the admin user
     config._cubes = None # avoid assertion error
     repo = get_repository(config=config)
+    # replace previous schema by the new repo's one. This is necessary so that we give the proper
+    # schema to `initialize_schema` above since it will initialize .eid attribute of schema elements
+    schema = repo.schema
     with connect(repo, login, password=pwd) as cnx:
         with cnx.security_enabled(False, False):
             repo.system_source.eid = ssource.eid # redo this manually
--- a/cubicweb/server/repository.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/repository.py	Wed Feb 17 13:45:34 2016 +0100
@@ -226,6 +226,11 @@
             if not config.creating:
                 self.info("set fs instance'schema")
             self.set_schema(config.load_schema(expand_cubes=True))
+            if not config.creating:
+                # set eids on entities schema
+                with self.internal_cnx() as cnx:
+                    for etype, eid in cnx.execute('Any XN,X WHERE X is CWEType, X name XN'):
+                        self.schema.eschema(etype).eid = eid
         else:
             # normal start: load the instance schema from the database
             self.info('loading schema from the repository')
--- a/cubicweb/server/sources/datafeed.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/sources/datafeed.py	Wed Feb 17 13:45:34 2016 +0100
@@ -356,7 +356,7 @@
             self.source.info('Using cwclientlib for %s' % url)
             resp = cnx.get(url)
             resp.raise_for_status()
-            return URLLibResponseAdapter(BytesIO(resp.text), url)
+            return URLLibResponseAdapter(BytesIO(resp.content), url)
         except (ImportError, ValueError, EnvironmentError) as exc:
             # ImportError: not available
             # ValueError: no config entry found
--- a/cubicweb/server/sources/native.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/sources/native.py	Wed Feb 17 13:45:34 2016 +0100
@@ -43,7 +43,7 @@
 
 from logilab.common.decorators import cached, clear_cache
 from logilab.common.configuration import Method
-from logilab.common.shellutils import getlogin
+from logilab.common.shellutils import getlogin, ASK
 from logilab.database import get_db_helper, sqlgen
 
 from yams.schema import role_name
@@ -56,7 +56,7 @@
 from cubicweb.cwconfig import CubicWebNoAppConfiguration
 from cubicweb.server import hook
 from cubicweb.server import schema2sql as y2sql
-from cubicweb.server.utils import crypt_password, eschema_eid, verify_and_update
+from cubicweb.server.utils import crypt_password, verify_and_update
 from cubicweb.server.sqlutils import SQL_PREFIX, SQLAdapterMixIn
 from cubicweb.server.rqlannotation import set_qdata
 from cubicweb.server.hook import CleanupDeletedEidsCacheOp
@@ -915,17 +915,14 @@
                  'asource': text_type(source.uri)}
         self._handle_insert_entity_sql(cnx, self.sqlgen.insert('entities', attrs), attrs)
         # insert core relations: is, is_instance_of and cw_source
-        try:
+
+        if entity.e_schema.eid is not None:  # else schema has not yet been serialized
             self._handle_is_relation_sql(cnx, 'INSERT INTO is_relation(eid_from,eid_to) VALUES (%s,%s)',
-                                         (entity.eid, eschema_eid(cnx, entity.e_schema)))
-        except IndexError:
-            # during schema serialization, skip
-            pass
-        else:
+                                         (entity.eid, entity.e_schema.eid))
             for eschema in entity.e_schema.ancestors() + [entity.e_schema]:
                 self._handle_is_relation_sql(cnx,
                                              'INSERT INTO is_instance_of_relation(eid_from,eid_to) VALUES (%s,%s)',
-                                             (entity.eid, eschema_eid(cnx, eschema)))
+                                             (entity.eid, eschema.eid))
         if 'CWSource' in self.schema and source.eid is not None: # else, cw < 3.10
             self._handle_is_relation_sql(cnx, 'INSERT INTO cw_source_relation(eid_from,eid_to) VALUES (%s,%s)',
                                          (entity.eid, source.eid))
@@ -1742,15 +1739,20 @@
         tables = archive.read('tables.txt').splitlines()
         sequences = archive.read('sequences.txt').splitlines()
         numranges = archive.read('numranges.txt').splitlines()
-        file_versions = self._parse_versions(archive.read('versions.txt'))
-        versions = set(self._get_versions())
-        if file_versions != versions:
-            self.logger.critical('Unable to restore : versions do not match')
-            self.logger.critical('Expected:\n%s', '\n'.join('%s : %s' % (cube, ver)
-                                                            for cube, ver in sorted(versions)))
-            self.logger.critical('Found:\n%s', '\n'.join('%s : %s' % (cube, ver)
-                                                         for cube, ver in sorted(file_versions)))
-            raise ValueError('Unable to restore : versions do not match')
+        archive_versions = self._parse_versions(archive.read('versions.txt'))
+        db_versions = set(self._get_versions())
+        if archive_versions != db_versions:
+            self.logger.critical('Restore warning: versions do not match')
+            new_cubes = db_versions - archive_versions
+            if new_cubes:
+                self.logger.critical('In the db:\n%s', '\n'.join('%s: %s' % (cube, ver)
+                                                            for cube, ver in sorted(new_cubes)))
+            old_cubes = archive_versions - db_versions
+            if old_cubes:
+                self.logger.critical('In the archive:\n%s', '\n'.join('%s: %s' % (cube, ver)
+                                                            for cube, ver in sorted(old_cubes)))
+            if not ASK.confirm('Versions mismatch: continue anyway ?', False):
+                raise ValueError('Unable to restore: versions do not match')
         table_chunks = {}
         for name in archive.namelist():
             if not name.startswith('tables/'):
--- a/cubicweb/server/test/data-migractions/migratedapp/schema.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/test/data-migractions/migratedapp/schema.py	Wed Feb 17 13:45:34 2016 +0100
@@ -211,3 +211,8 @@
 class same_as(RelationDefinition):
     subject = ('Societe',)
     object = 'ExternalUri'
+
+class inlined_rel(RelationDefinition):
+    subject = object = 'Folder2'
+    inlined = True
+    cardinality = '??'
--- a/cubicweb/server/test/unittest_migractions.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/test/unittest_migractions.py	Wed Feb 17 13:45:34 2016 +0100
@@ -276,10 +276,10 @@
                                'description', 'description_format',
                                'eid',
                                'filed_under2', 'has_text',
-                               'identity', 'in_basket', 'is', 'is_instance_of',
+                               'identity', 'in_basket', 'inlined_rel', 'is', 'is_instance_of',
                                'modification_date', 'name', 'owned_by'])
             self.assertCountEqual([str(rs) for rs in self.schema['Folder2'].object_relations()],
-                                  ['filed_under2', 'identity'])
+                                  ['filed_under2', 'identity', 'inlined_rel'])
             # Old will be missing as it has been renamed into 'New' in the migrated
             # schema while New hasn't been added here.
             self.assertEqual(sorted(str(e) for e in self.schema['filed_under2'].subjects()),
--- a/cubicweb/server/utils.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/server/utils.py	Wed Feb 17 13:45:34 2016 +0100
@@ -1,4 +1,4 @@
-# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -31,6 +31,8 @@
 from passlib.utils import handlers as uh, to_hash_str
 from passlib.context import CryptContext
 
+from logilab.common.deprecation import deprecated
+
 from cubicweb.md5crypt import crypt as md5crypt
 
 
@@ -76,16 +78,14 @@
     # wrong password
     return b''
 
-
+@deprecated('[3.22] no more necessary, directly get eschema.eid')
 def eschema_eid(cnx, eschema):
-    """get eid of the CWEType entity for the given yams type. You should use
-    this because when schema has been loaded from the file-system, not from the
-    database, (e.g. during tests), eschema.eid is not set.
+    """get eid of the CWEType entity for the given yams type.
+
+    This used to be necessary because when the schema has been loaded from the
+    file-system, not from the database, (e.g. during tests), eschema.eid was
+    not set.
     """
-    if eschema.eid is None:
-        eschema.eid = cnx.execute(
-            'Any X WHERE X is CWEType, X name %(name)s',
-            {'name': text_type(eschema)})[0][0]
     return eschema.eid
 
 
--- a/cubicweb/skeleton/DISTNAME.spec.tmpl	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/skeleton/DISTNAME.spec.tmpl	Wed Feb 17 13:45:34 2016 +0100
@@ -44,4 +44,4 @@
 
 %%files
 %%defattr(-, root, root)
-/*
+%%{_prefix}/share/cubicweb/cubes/*
--- a/cubicweb/sobjects/test/unittest_notification.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/sobjects/test/unittest_notification.py	Wed Feb 17 13:45:34 2016 +0100
@@ -1,4 +1,3 @@
-# -*- coding: iso-8859-1 -*-
 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
@@ -16,47 +15,10 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""Tests for notification sobjects"""
 
-from socket import gethostname
-
-from logilab.common.testlib import unittest_main, TestCase
 from cubicweb.devtools.testlib import CubicWebTC, MAILBOX
 
-from cubicweb.mail import construct_message_id, parse_message_id
-
-class MessageIdTC(TestCase):
-    def test_base(self):
-        msgid1 = construct_message_id('testapp', 21)
-        msgid2 = construct_message_id('testapp', 21)
-        self.assertNotEqual(msgid1, msgid2)
-        self.assertNotIn('&', msgid1)
-        self.assertNotIn('=', msgid1)
-        self.assertNotIn('/', msgid1)
-        self.assertNotIn('+', msgid1)
-        values = parse_message_id(msgid1, 'testapp')
-        self.assertTrue(values)
-        # parse_message_id should work with or without surrounding <>
-        self.assertEqual(values, parse_message_id(msgid1[1:-1], 'testapp'))
-        self.assertEqual(values['eid'], '21')
-        self.assertIn('timestamp', values)
-        self.assertEqual(parse_message_id(msgid1[1:-1], 'anotherapp'), None)
-
-    def test_notimestamp(self):
-        msgid1 = construct_message_id('testapp', 21, False)
-        msgid2 = construct_message_id('testapp', 21, False)
-        values = parse_message_id(msgid1, 'testapp')
-        self.assertEqual(values, {'eid': '21'})
-
-    def test_parse_message_doesnt_raise(self):
-        self.assertEqual(parse_message_id('oijioj@bla.bla', 'tesapp'), None)
-        self.assertEqual(parse_message_id('oijioj@bla', 'tesapp'), None)
-        self.assertEqual(parse_message_id('oijioj', 'tesapp'), None)
-
-
-    def test_nonregr_empty_message_id(self):
-        for eid in (1, 12, 123, 1234):
-            msgid1 = construct_message_id('testapp', eid, 12)
-            self.assertNotEqual(msgid1, '<@testapp.%s>' % gethostname())
 
 class NotificationTC(CubicWebTC):
 
@@ -67,7 +29,7 @@
                         'WHERE U eid %(x)s', {'x': urset[0][0]})
             req.execute('INSERT CWProperty X: X pkey "ui.language", X value "fr", X for_user U '
                         'WHERE U eid %(x)s', {'x': urset[0][0]})
-            req.cnx.commit() # commit so that admin get its properties updated
+            req.cnx.commit()  # commit so that admin get its properties updated
             finder = self.vreg['components'].select('recipients_finder',
                                                     req, rset=urset)
             self.set_option('default-recipients-mode', 'none')
@@ -76,7 +38,8 @@
             self.assertEqual(finder.recipients(), [(u'admin@logilab.fr', 'fr')])
             self.set_option('default-recipients-mode', 'default-dest-addrs')
             self.set_option('default-dest-addrs', 'abcd@logilab.fr, efgh@logilab.fr')
-            self.assertEqual(list(finder.recipients()), [('abcd@logilab.fr', 'en'), ('efgh@logilab.fr', 'en')])
+            self.assertEqual(list(finder.recipients()),
+                             [('abcd@logilab.fr', 'en'), ('efgh@logilab.fr', 'en')])
 
     def test_status_change_view(self):
         with self.admin_access.web_request() as req:
@@ -99,5 +62,7 @@
             self.assertEqual(email.subject,
                              'status changed CWUser #%s (admin)' % u.eid)
 
+
 if __name__ == '__main__':
+    from logilab.common.testlib import unittest_main
     unittest_main()
--- a/cubicweb/test/unittest_mail.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/test/unittest_mail.py	Wed Feb 17 13:45:34 2016 +0100
@@ -16,19 +16,18 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
-"""unit tests for module cubicweb.mail
-
-"""
+"""unit tests for module cubicweb.mail"""
 
 import os
 import re
+from socket import gethostname
 import sys
+from unittest import TestCase
 
-from logilab.common.testlib import unittest_main
 from logilab.common.umessage import message_from_string
 
 from cubicweb.devtools.testlib import CubicWebTC
-from cubicweb.mail import format_mail
+from cubicweb.mail import format_mail, construct_message_id, parse_message_id
 
 
 def getlogin():
@@ -74,7 +73,6 @@
         self.assertEqual(msg.get('reply-to'), u'oim <oim@logilab.fr>, BimBam <bim@boum.fr>')
         self.assertEqual(msg.get_payload(decode=True), u'un petit cöucou')
 
-
     def test_format_mail_euro(self):
         mail = format_mail({'name': u'oîm', 'email': u'oim@logilab.fr'},
                            ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €')
@@ -99,7 +97,6 @@
         self.assertEqual(msg.get('reply-to'), u'oîm <oim@logilab.fr>')
         self.assertEqual(msg.get_payload(decode=True), u'un petit cöucou €')
 
-
     def test_format_mail_from_reply_to(self):
         # no sender-name, sender-addr in the configuration
         self.set_option('sender-name', '')
@@ -125,18 +122,20 @@
         self.set_option('sender-addr', 'cubicweb-test@logilab.fr')
         # anonymous notification: no name and no email specified
         msg = format_mail({'name': u'', 'email': u''},
-                           ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €',
-                           config=self.config)
+                          ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €',
+                          config=self.config)
         msg = message_from_string(msg.as_string())
         self.assertEqual(msg.get('from'), u'cubicweb-test <cubicweb-test@logilab.fr>')
         self.assertEqual(msg.get('reply-to'), u'cubicweb-test <cubicweb-test@logilab.fr>')
         # anonymous notification: only email specified
         msg = format_mail({'email': u'tutu@logilab.fr'},
-                           ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €',
-                           config=self.config)
+                          ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €',
+                          config=self.config)
         msg = message_from_string(msg.as_string())
         self.assertEqual(msg.get('from'), u'cubicweb-test <tutu@logilab.fr>')
-        self.assertEqual(msg.get('reply-to'), u'cubicweb-test <tutu@logilab.fr>, cubicweb-test <cubicweb-test@logilab.fr>')
+        self.assertEqual(
+            msg.get('reply-to'),
+            u'cubicweb-test <tutu@logilab.fr>, cubicweb-test <cubicweb-test@logilab.fr>')
         # anonymous notification: only name specified
         msg = format_mail({'name': u'tutu'},
                           ['test@logilab.fr'], u'un petit cöucou €', u'bïjour €',
@@ -146,6 +145,41 @@
         self.assertEqual(msg.get('reply-to'), u'tutu <cubicweb-test@logilab.fr>')
 
 
+class MessageIdTC(TestCase):
+
+    def test_base(self):
+        msgid1 = construct_message_id('testapp', 21)
+        msgid2 = construct_message_id('testapp', 21)
+        self.assertNotEqual(msgid1, msgid2)
+        self.assertNotIn('&', msgid1)
+        self.assertNotIn('=', msgid1)
+        self.assertNotIn('/', msgid1)
+        self.assertNotIn('+', msgid1)
+        values = parse_message_id(msgid1, 'testapp')
+        self.assertTrue(values)
+        # parse_message_id should work with or without surrounding <>
+        self.assertEqual(values, parse_message_id(msgid1[1:-1], 'testapp'))
+        self.assertEqual(values['eid'], '21')
+        self.assertIn('timestamp', values)
+        self.assertEqual(parse_message_id(msgid1[1:-1], 'anotherapp'), None)
+
+    def test_notimestamp(self):
+        msgid1 = construct_message_id('testapp', 21, False)
+        construct_message_id('testapp', 21, False)
+        values = parse_message_id(msgid1, 'testapp')
+        self.assertEqual(values, {'eid': '21'})
+
+    def test_parse_message_doesnt_raise(self):
+        self.assertEqual(parse_message_id('oijioj@bla.bla', 'tesapp'), None)
+        self.assertEqual(parse_message_id('oijioj@bla', 'tesapp'), None)
+        self.assertEqual(parse_message_id('oijioj', 'tesapp'), None)
+
+    def test_nonregr_empty_message_id(self):
+        for eid in (1, 12, 123, 1234):
+            msgid1 = construct_message_id('testapp', eid, 12)
+            self.assertNotEqual(msgid1, '<@testapp.%s>' % gethostname())
+
 
 if __name__ == '__main__':
+    from logilab.common.testlib import unittest_main
     unittest_main()
--- a/cubicweb/web/component.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/component.py	Wed Feb 17 13:45:34 2016 +0100
@@ -71,10 +71,13 @@
         except AttributeError:
             page_size = self.cw_extra_kwargs.get('page_size')
             if page_size is None:
-                if 'page_size' in self._cw.form:
-                    page_size = int(self._cw.form['page_size'])
-                else:
-                    page_size = self._cw.property_value(self.page_size_property)
+                try:
+                    page_size = int(self._cw.form.get('page_size'))
+                except (ValueError, TypeError):
+                    # no or invalid value, fall back
+                    pass
+            if page_size is None:
+                page_size = self._cw.property_value(self.page_size_property)
             self._page_size = page_size
             return page_size
 
--- a/cubicweb/web/data/cubicweb.goa.js	Thu Feb 11 21:59:49 2016 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-/**
- *  functions specific to cubicweb on google appengine
- *
- *  :organization: Logilab
- *  :copyright: 2008-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
- *  :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
- */
-
-/**
- * .. function:: rql_for_eid(eid)
- *
- * overrides rql_for_eid function from htmlhelpers.hs
- */
-function rql_for_eid(eid) {
-        return 'Any X WHERE X eid "' + eid + '"';
-}
--- a/cubicweb/web/formfields.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/formfields.py	Wed Feb 17 13:45:34 2016 +0100
@@ -78,7 +78,7 @@
 from yams.constraints import (SizeConstraint, StaticVocabularyConstraint,
                               FormatConstraint)
 
-from cubicweb import Binary, tags, uilib
+from cubicweb import Binary, tags, uilib, neg_role
 from cubicweb.utils import support_args
 from cubicweb.web import INTERNAL_FIELD_VALUE, ProcessFormError, eid_param, \
      formwidgets as fw
@@ -1205,10 +1205,13 @@
     else:
         targetschema = rdef.subject
     card = rdef.role_cardinality(role)
+    composite = getattr(rdef, 'composite', None)
     kwargs['name'] = rschema.type
     kwargs['role'] = role
     kwargs['eidparam'] = True
-    kwargs.setdefault('required', card in '1+')
+    # don't mark composite relation as required, we want the composite element
+    # to be removed when not linked to its parent
+    kwargs.setdefault('required', card in '1+' and composite != neg_role(role))
     if role == 'object':
         kwargs.setdefault('label', (eschema.type, rschema.type + '_object'))
     else:
--- a/cubicweb/web/test/data/schema.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/test/data/schema.py	Wed Feb 17 13:45:34 2016 +0100
@@ -95,6 +95,33 @@
 class Ticket(EntityType):
     title = String(maxsize=32, required=True, fulltextindexed=True)
     concerns = SubjectRelation('Project', composite='object')
+    in_version = SubjectRelation('Version', composite='object',
+                                 cardinality='?*', inlined=True)
+
+class Version(EntityType):
+    name = String(required=True)
+
+class Filesystem(EntityType):
+    name = String()
+
+class DirectoryPermission(EntityType):
+    value = String()
+
+class parent_fs(RelationDefinition):
+    name = 'parent'
+    subject = 'Directory'
+    object = 'Filesystem'
+
+class Directory(EntityType):
+    name = String(required=True)
+    has_permission = SubjectRelation('DirectoryPermission', cardinality='*1',
+                                     composite='subject')
+
+class parent_directory(RelationDefinition):
+    name = 'parent'
+    subject = 'Directory'
+    object = 'Directory'
+    composite = 'object'
 
 class Folder(EntityType):
     name = String(required=True)
--- a/cubicweb/web/test/unittest_application.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/test/unittest_application.py	Wed Feb 17 13:45:34 2016 +0100
@@ -26,7 +26,6 @@
 from logilab.common.testlib import TestCase, unittest_main
 from logilab.common.decorators import clear_cache, classproperty
 
-from cubicweb import AuthenticationError
 from cubicweb import view
 from cubicweb.devtools.testlib import CubicWebTC, real_error_handling
 from cubicweb.devtools.fake import FakeRequest
@@ -260,6 +259,259 @@
                               {'login-subject': u'the value "admin" is already used, use another one'})
             self.assertEqual(forminfo['values'], req.form)
 
+    def _edit_parent(self, dir_eid, parent_eid, role='subject',
+                     etype='Directory', **kwargs):
+        parent_eid = parent_eid or '__cubicweb_internal_field__'
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': unicode(dir_eid),
+                '__maineid': unicode(dir_eid),
+                '__type:%s' % dir_eid: etype,
+                'parent-%s:%s' % (role, dir_eid): parent_eid,
+            }
+            req.form.update(kwargs)
+            req.form['_cw_entity_fields:%s' % dir_eid] = ','.join(
+                ['parent-%s' % role] +
+                [key.split(':')[0]
+                 for key in kwargs.keys()
+                 if not key.startswith('_')])
+            self.expect_redirect_handle_request(req)
+
+    def _edit_in_version(self, ticket_eid, version_eid, **kwargs):
+        version_eid = version_eid or '__cubicweb_internal_field__'
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': unicode(ticket_eid),
+                '__maineid': unicode(ticket_eid),
+                '__type:%s' % ticket_eid: 'Ticket',
+                'in_version-subject:%s' % ticket_eid: version_eid,
+            }
+            req.form.update(kwargs)
+            req.form['_cw_entity_fields:%s' % ticket_eid] = ','.join(
+                ['in_version-subject'] +
+                [key.split(':')[0]
+                 for key in kwargs.keys()
+                 if not key.startswith('_')])
+            self.expect_redirect_handle_request(req)
+
+    def test_create_and_link_directories(self):
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': (u'A', u'B'),
+                '__maineid': u'A',
+                '__type:A': 'Directory',
+                '__type:B': 'Directory',
+                'parent-subject:B': u'A',
+                'name-subject:A': u'topd',
+                'name-subject:B': u'subd',
+                '_cw_entity_fields:A': 'name-subject',
+                '_cw_entity_fields:B': 'parent-subject,name-subject',
+            }
+            self.expect_redirect_handle_request(req)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', name=u'topd'))
+            self.assertTrue(cnx.find('Directory', name=u'subd'))
+            self.assertEqual(1, cnx.execute(
+                'Directory SUBD WHERE SUBD parent TOPD,'
+                ' SUBD name "subd", TOPD name "topd"').rowcount)
+
+    def test_create_subentity(self):
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            cnx.commit()
+
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': (unicode(topd.eid), u'B'),
+                '__maineid': unicode(topd.eid),
+                '__type:%s' % topd.eid: 'Directory',
+                '__type:B': 'Directory',
+                'parent-object:%s' % topd.eid: u'B',
+                'name-subject:B': u'subd',
+                '_cw_entity_fields:%s' % topd.eid: 'parent-object',
+                '_cw_entity_fields:B': 'name-subject',
+            }
+            self.expect_redirect_handle_request(req)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', name=u'topd'))
+            self.assertTrue(cnx.find('Directory', name=u'subd'))
+            self.assertEqual(1, cnx.execute(
+                'Directory SUBD WHERE SUBD parent TOPD,'
+                ' SUBD name "subd", TOPD name "topd"').rowcount)
+
+    def test_subject_subentity_removal(self):
+        """Editcontroller: detaching a composite relation removes the subentity
+        (edit from the subject side)
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
+            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
+            cnx.commit()
+
+        attrs = {'name-subject:%s' % sub1.eid: ''}
+        self._edit_parent(sub1.eid, parent_eid=None, **attrs)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=sub1.eid))
+            self.assertTrue(cnx.find('Directory', eid=sub2.eid))
+
+    def test_object_subentity_removal(self):
+        """Editcontroller: detaching a composite relation removes the subentity
+        (edit from the object side)
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
+            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
+            cnx.commit()
+
+        self._edit_parent(topd.eid, parent_eid=sub1.eid, role='object')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=sub1.eid))
+            self.assertFalse(cnx.find('Directory', eid=sub2.eid))
+
+    def test_reparent_subentity(self):
+        "Editcontroller: re-parenting a subentity does not remove it"
+        with self.admin_access.repo_cnx() as cnx:
+            top1 = cnx.create_entity('Directory', name=u'top1')
+            top2 = cnx.create_entity('Directory', name=u'top2')
+            subd = cnx.create_entity('Directory', name=u'subd', parent=top1)
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=top2.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=top1.eid))
+            self.assertTrue(cnx.find('Directory', eid=top2.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertEqual(
+                cnx.find('Directory', eid=subd.eid).one().parent[0], top2)
+
+    def test_reparent_subentity_inlined(self):
+        """Editcontroller: re-parenting a subentity does not remove it
+        (inlined case)"""
+        with self.admin_access.repo_cnx() as cnx:
+            version1 = cnx.create_entity('Version', name=u'version1')
+            version2 = cnx.create_entity('Version', name=u'version2')
+            ticket = cnx.create_entity('Ticket', title=u'ticket',
+                                       in_version=version1)
+            cnx.commit()
+
+        self._edit_in_version(ticket.eid, version_eid=version2.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Version', eid=version1.eid))
+            self.assertTrue(cnx.find('Version', eid=version2.eid))
+            self.assertTrue(cnx.find('Ticket', eid=ticket.eid))
+            self.assertEqual(
+                cnx.find('Ticket', eid=ticket.eid).one().in_version[0], version2)
+
+    def test_subject_mixed_composite_subentity_removal_1(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove non composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=topd.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
+                             [topd,])
+
+    def test_subject_mixed_composite_subentity_removal_2(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=fs.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+
+    def test_object_mixed_composite_subentity_removal_1(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove non composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(fs.eid, parent_eid=None, role='object',
+                          etype='Filesystem')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
+                             [topd,])
+
+    def test_object_mixed_composite_subentity_removal_2(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(topd.eid, parent_eid=None, role='object')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+
+    def test_delete_mandatory_composite(self):
+        with self.admin_access.repo_cnx() as cnx:
+            perm = cnx.create_entity('DirectoryPermission')
+            mydir = cnx.create_entity('Directory', name=u'dir',
+                                      has_permission=perm)
+            cnx.commit()
+
+        with self.admin_access.web_request() as req:
+            dir_eid = unicode(mydir.eid)
+            perm_eid = unicode(perm.eid)
+            req.form = {
+                'eid': [dir_eid, perm_eid],
+                '__maineid' : dir_eid,
+                '__type:%s' % dir_eid: 'Directory',
+                '__type:%s' % perm_eid: 'DirectoryPermission',
+                '_cw_entity_fields:%s' % dir_eid: '',
+                '_cw_entity_fields:%s' % perm_eid: 'has_permission-object',
+                'has_permission-object:%s' % perm_eid: '',
+                }
+            path, _params = self.expect_redirect_handle_request(req, 'edit')
+            self.assertTrue(req.find('Directory', eid=mydir.eid))
+            self.assertFalse(req.find('DirectoryPermission', eid=perm.eid))
+
     def test_ajax_view_raise_arbitrary_error(self):
         class ErrorAjaxView(view.View):
             __regid__ = 'test.ajax.error'
--- a/cubicweb/web/test/unittest_views_editforms.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/test/unittest_views_editforms.py	Wed Feb 17 13:45:34 2016 +0100
@@ -15,7 +15,9 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+
 from logilab.common.testlib import unittest_main, mock_object
+from logilab.common import tempattr
 
 from cubicweb.devtools.testlib import CubicWebTC
 from cubicweb.web.views import uicfg
@@ -181,6 +183,22 @@
                 autoform = self.vreg['forms'].select('edition', req, entity=req.user)
                 self.assertEqual(list(autoform.inlined_form_views()), [])
 
+    def test_inlined_form_views(self):
+        # when some relation has + cardinality, and some already linked entities which are not
+        # updatable, a link to optionally add a new sub-entity should be displayed, not a sub-form
+        # forcing creation of a sub-entity
+        from cubicweb.web.views import autoform
+        with self.admin_access.web_request() as req:
+            req.create_entity('EmailAddress', address=u'admin@cubicweb.org',
+                              reverse_use_email=req.user.eid)
+            use_email_schema = self.vreg.schema['CWUser'].rdef('use_email')
+            with tempattr(use_email_schema, 'cardinality', '+1'):
+                with self.temporary_permissions(EmailAddress={'update': ()}):
+                    form = self.vreg['forms'].select('edition', req, entity=req.user)
+                    formviews = list(form.inlined_form_views())
+                    self.assertEqual(len(formviews), 1, formviews)
+                    self.assertIsInstance(formviews[0], autoform.InlineAddNewLinkView)
+
     def test_check_inlined_rdef_permissions(self):
         # try to check permissions when creating an entity ('user' below is a
         # fresh entity without an eid)
--- a/cubicweb/web/views/autoform.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/views/autoform.py	Wed Feb 17 13:45:34 2016 +0100
@@ -899,14 +899,13 @@
             ttype = tschema.type
             formviews = list(self.inline_edition_form_view(rschema, ttype, role))
             card = rschema.role_rdef(entity.e_schema, ttype, role).role_cardinality(role)
-            # there is no related entity and we need at least one: we need to
-            # display one explicit inline-creation view
-            if self.should_display_inline_creation_form(rschema, formviews, card):
+            related = entity.has_eid() and entity.related(rschema, role)
+            if self.should_display_inline_creation_form(rschema, related, card):
                 formviews += self.inline_creation_form_view(rschema, ttype, role)
             # we can create more than one related entity, we thus display a link
             # to add new related entities
             if self.must_display_add_new_relation_link(rschema, role, tschema,
-                                                       ttype, formviews, card):
+                                                       ttype, related, card):
                 addnewlink = self._cw.vreg['views'].select(
                     'inline-addnew-link', self._cw,
                     etype=ttype, rtype=rschema, role=role, card=card,
--- a/cubicweb/web/views/editcontroller.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/cubicweb/web/views/editcontroller.py	Wed Feb 17 13:45:34 2016 +0100
@@ -31,7 +31,7 @@
 
 from rql.utils import rqlvar_maker
 
-from cubicweb import _, Binary, ValidationError
+from cubicweb import _, Binary, ValidationError, UnknownEid
 from cubicweb.view import EntityAdapter
 from cubicweb.predicates import is_instance
 from cubicweb.web import (INTERNAL_FIELD_VALUE, RequestError, NothingToEdit,
@@ -79,12 +79,14 @@
         self.edited = []
         self.restrictions = []
         self.kwargs = {}
+        self.canceled = False
 
     def __repr__(self):
         return ('Query <edited=%r restrictions=%r kwargs=%r>' % (
             self.edited, self.restrictions, self.kwargs))
 
     def insert_query(self, etype):
+        assert not self.canceled
         if self.edited:
             rql = 'INSERT %s X: %s' % (etype, ','.join(self.edited))
         else:
@@ -94,6 +96,7 @@
         return rql
 
     def update_query(self, eid):
+        assert not self.canceled
         varmaker = rqlvar_maker()
         var = next(varmaker)
         while var in self.kwargs:
@@ -192,6 +195,7 @@
         # deserves special treatment
         req.data['pending_inlined'] = defaultdict(set)
         req.data['pending_others'] = set()
+        req.data['pending_composite_delete'] = set()
         try:
             for formparams in self._ordered_formparams():
                 eid = self.edit_entity(formparams)
@@ -210,6 +214,9 @@
         # then execute rql to set all relations
         for querydef in self.relations_rql:
             self._cw.execute(*querydef)
+        # delete pending composite
+        for entity in req.data['pending_composite_delete']:
+            entity.cw_delete()
         # XXX this processes *all* pending operations of *all* entities
         if '__delete' in req.form:
             todelete = req.list_form_param('__delete', req.form, pop=True)
@@ -266,14 +273,17 @@
         # creation, add relevant data to the rqlquery
         for form_, field in req.data['pending_inlined'].pop(entity.eid, ()):
             rqlquery.set_inlined(field.name, form_.edited_entity.eid)
-        if self.errors:
-            errors = dict((f.role_name(), text_type(ex)) for f, ex in self.errors)
-            raise ValidationError(valerror_eid(entity.eid), errors)
-        if eid is None: # creation or copy
-            entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
-        elif rqlquery.edited: # edition of an existant entity
-            self.check_concurrent_edition(formparams, eid)
-            self._update_entity(eid, rqlquery)
+        if not rqlquery.canceled:
+            if self.errors:
+                errors = dict((f.role_name(), text_type(ex)) for f, ex in self.errors)
+                raise ValidationError(valerror_eid(entity.eid), errors)
+            if eid is None: # creation or copy
+                entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
+            elif rqlquery.edited: # edition of an existant entity
+                self.check_concurrent_edition(formparams, eid)
+                self._update_entity(eid, rqlquery)
+        else:
+            self.errors = []
         if is_main_entity:
             self.notify_edited(entity)
         if '__delete' in formparams:
@@ -287,7 +297,8 @@
         return eid
 
     def handle_formfield(self, form, field, rqlquery=None):
-        eschema = form.edited_entity.e_schema
+        entity = form.edited_entity
+        eschema = entity.e_schema
         try:
             for field, value in field.process_posted(form):
                 if not (
@@ -295,25 +306,74 @@
                     or
                     (field.role == 'object' and field.name in eschema.objrels)):
                     continue
+
                 rschema = self._cw.vreg.schema.rschema(field.name)
                 if rschema.final:
                     rqlquery.set_attribute(field.name, value)
+                    continue
+
+                if entity.has_eid():
+                    origvalues = set(data[0] for data in entity.related(field.name, field.role).rows)
                 else:
-                    if form.edited_entity.has_eid():
-                        origvalues = set(entity.eid for entity in form.edited_entity.related(field.name, field.role, entities=True))
-                    else:
-                        origvalues = set()
-                    if value is None or value == origvalues:
-                        continue # not edited / not modified / to do later
-                    if rschema.inlined and rqlquery is not None and field.role == 'subject':
-                        self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
-                    elif form.edited_entity.has_eid():
-                        self.handle_relation(form, field, value, origvalues)
-                    else:
-                        form._cw.data['pending_others'].add( (form, field) )
+                    origvalues = set()
+                if value is None or value == origvalues:
+                    continue # not edited / not modified / to do later
+
+                unlinked_eids = origvalues - value
+
+                if unlinked_eids:
+                    # Special handling of composite relation removal
+                    self.handle_composite_removal(
+                        form, field, unlinked_eids, value, rqlquery)
+
+                if rschema.inlined and rqlquery is not None and field.role == 'subject':
+                    self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
+                elif form.edited_entity.has_eid():
+                    self.handle_relation(form, field, value, origvalues)
+                else:
+                    form._cw.data['pending_others'].add( (form, field) )
+
         except ProcessFormError as exc:
             self.errors.append((field, exc))
 
+    def handle_composite_removal(self, form, field,
+                                 removed_values, new_values, rqlquery):
+        """
+        In EditController-handled forms, when the user removes a composite
+        relation, it triggers the removal of the related entity in the
+        composite. This is where this happens.
+
+        See for instance test_subject_subentity_removal in
+        web/test/unittest_application.py.
+        """
+        rschema = self._cw.vreg.schema.rschema(field.name)
+        new_value_etypes = set()
+        # the user could have included nonexisting eids in the POST; don't crash.
+        for eid in new_values:
+            try:
+                new_value_etypes.add(self._cw.entity_from_eid(eid).cw_etype)
+            except UnknownEid:
+                continue
+        for unlinked_eid in removed_values:
+            unlinked_entity = self._cw.entity_from_eid(unlinked_eid)
+            rdef = rschema.role_rdef(form.edited_entity.cw_etype,
+                                     unlinked_entity.cw_etype,
+                                     field.role)
+            if rdef.composite is not None:
+                if rdef.composite == field.role:
+                    to_be_removed = unlinked_entity
+                else:
+                    if unlinked_entity.cw_etype in new_value_etypes:
+                        # This is a same-rdef re-parenting: do not remove the entity
+                        continue
+                    to_be_removed = form.edited_entity
+                    self.info('Edition of %s is cancelled (deletion requested)',
+                              to_be_removed)
+                    rqlquery.canceled = True
+                self.info('Scheduling removal of %s as composite relation '
+                          '%s was removed', to_be_removed, rdef)
+                form._cw.data['pending_composite_delete'].add(to_be_removed)
+
     def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
         """handle edition for the (rschema, x) relation of the given entity
         """
--- a/debian/changelog	Thu Feb 11 21:59:49 2016 +0100
+++ b/debian/changelog	Wed Feb 17 13:45:34 2016 +0100
@@ -1,9 +1,21 @@
+cubicweb (3.22.1-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Fri, 12 Feb 2016 10:38:56 +0100
+
 cubicweb (3.22.0-1) unstable; urgency=medium
 
   * new upstream release
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Mon, 04 Jan 2016 17:53:55 +0100
 
+cubicweb (3.21.6-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 18:53:15 +0100
+
 cubicweb (3.21.5-2) unstable; urgency=medium
 
   * Fix conflict between cubicweb-server and cubicweb-dev.
@@ -46,6 +58,12 @@
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Fri, 10 Jul 2015 17:04:11 +0200
 
+cubicweb (3.20.13-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 17:53:29 +0100
+
 cubicweb (3.20.12-1) unstable; urgency=medium
 
   * new upstream release.
@@ -130,6 +148,12 @@
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 06 Jan 2015 18:11:03 +0100
 
+cubicweb (3.19.14-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 11:01:47 +0100
+
 cubicweb (3.19.13-1) unstable; urgency=medium
 
   * New upstream release.
--- a/debian/control	Thu Feb 11 21:59:49 2016 +0100
+++ b/debian/control	Wed Feb 17 13:45:34 2016 +0100
@@ -56,7 +56,8 @@
  cubicweb-postgresql-support
  | cubicweb-mysql-support
  | python-pysqlite2,
- python-passlib
+ python-passlib,
+ python-tz,
 Recommends:
  cubicweb-documentation (= ${source:Version}),
 Suggests:
--- a/doc/book/devrepo/migration.rst	Thu Feb 11 21:59:49 2016 +0100
+++ b/doc/book/devrepo/migration.rst	Wed Feb 17 13:45:34 2016 +0100
@@ -108,6 +108,7 @@
 If some of the added cubes are already used by an instance, they'll simply be
 silently skipped.
 
+To remove a cube use `drop_cube(cube, removedeps=False)`. 
 
 Schema migration
 ----------------
--- a/doc/changes/3.22.rst	Thu Feb 11 21:59:49 2016 +0100
+++ b/doc/changes/3.22.rst	Wed Feb 17 13:45:34 2016 +0100
@@ -89,3 +89,6 @@
 * the ``Repository.pinfo()`` method was removed
 
 * the ``cubicweb.utils.SizeConstrainedList`` class was removed
+
+* the 'startorder' file in configuration directory is no longer honored
+
--- a/doc/tools/pyjsrest.py	Thu Feb 11 21:59:49 2016 +0100
+++ b/doc/tools/pyjsrest.py	Wed Feb 17 13:45:34 2016 +0100
@@ -152,7 +152,6 @@
 
     'cubicweb.fckcwconfig.js',
     'cubicweb.fckcwconfig-full.js',
-    'cubicweb.goa.js',
     'cubicweb.compat.js',
     ])