merge 3.19 heads

Getting an unexpected test error though: ERROR: test_edit_mandatory_inlined3_object (unittest_views_basecontrollers.EditControllerTC)

authorJulien Cristau <julien.cristau@logilab.fr>
changeset7eb9e3e254bc
branchdefault
phasepublic
hiddenno
parent revision#dcbb64d3a1d9 [edit controller] Fix composite entity reparenting when inlined, #42a4d52dc88d [views] fix ProcessInformationView: SESSION_MANAGER can be None (closes #5753280)
child revision#468b91aabd9d [web/views] fix editcontroller regression
files modified by this revision
server/sources/native.py
web/formfields.py
web/test/data/schema.py
web/test/unittest_application.py
web/views/editcontroller.py
# HG changeset patch
# User Julien Cristau <julien.cristau@logilab.fr>
# Date 1454508752 -3600
# Wed Feb 03 15:12:32 2016 +0100
# Node ID 7eb9e3e254bc8aaa8fb4252fa52b5c1000072d9e
# Parent 42a4d52dc88d88cd4dc5443fcba66eecef09f1d7
# Parent dcbb64d3a1d91656e39fa8cdf4c8a9e245e37ecd
merge 3.19 heads

Getting an unexpected test error though:
ERROR: test_edit_mandatory_inlined3_object (unittest_views_basecontrollers.EditControllerTC)

diff --git a/server/sources/native.py b/server/sources/native.py
@@ -38,11 +38,11 @@
1  import logging
2  import sys
3 
4  from logilab.common.decorators import cached, clear_cache
5  from logilab.common.configuration import Method
6 -from logilab.common.shellutils import getlogin
7 +from logilab.common.shellutils import getlogin, ASK
8  from logilab.database import get_db_helper, sqlgen
9 
10  from yams import schema2sql as y2sql
11  from yams.schema import role_name
12 
@@ -1706,19 +1706,24 @@
13              self.logger.critical('Unsupported format in archive: %s', formatinfo)
14              raise ValueError('Unknown format in %s: %s' % (backupfile, formatinfo))
15          tables = archive.read('tables.txt').splitlines()
16          sequences = archive.read('sequences.txt').splitlines()
17          numranges = archive.read('numranges.txt').splitlines()
18 -        file_versions = self._parse_versions(archive.read('versions.txt'))
19 -        versions = set(self._get_versions())
20 -        if file_versions != versions:
21 -            self.logger.critical('Unable to restore : versions do not match')
22 -            self.logger.critical('Expected:\n%s', '\n'.join('%s : %s' % (cube, ver)
23 -                                                            for cube, ver in sorted(versions)))
24 -            self.logger.critical('Found:\n%s', '\n'.join('%s : %s' % (cube, ver)
25 -                                                         for cube, ver in sorted(file_versions)))
26 -            raise ValueError('Unable to restore : versions do not match')
27 +        archive_versions = self._parse_versions(archive.read('versions.txt'))
28 +        db_versions = set(self._get_versions())
29 +        if archive_versions != db_versions:
30 +            self.logger.critical('Restore warning : versions do not match')
31 +            new_cubes = db_versions - archive_versions
32 +            if new_cubes:
33 +                self.logger.critical('In the db:\n%s', '\n'.join('%s : %s' % (cube, ver)
34 +                                                            for cube, ver in sorted(new_cubes)))
35 +            old_cubes = archive_versions - db_versions
36 +            if old_cubes:
37 +                self.logger.critical('In the archive:\n%s', '\n'.join('%s : %s' % (cube, ver)
38 +                                                            for cube, ver in sorted(old_cubes)))
39 +            if not ASK.confirm('Versions mismatch: continue anyway ?', False):
40 +                raise ValueError('Unable to restore : versions do not match')
41          table_chunks = {}
42          for name in archive.namelist():
43              if not name.startswith('tables/'):
44                  continue
45              filename = basename(name)
diff --git a/web/formfields.py b/web/formfields.py
@@ -74,11 +74,11 @@
46 
47  from yams.schema import KNOWN_METAATTRIBUTES, role_name
48  from yams.constraints import (SizeConstraint, StaticVocabularyConstraint,
49                                FormatConstraint)
50 
51 -from cubicweb import Binary, tags, uilib
52 +from cubicweb import Binary, tags, uilib, neg_role
53  from cubicweb.utils import support_args
54  from cubicweb.web import INTERNAL_FIELD_VALUE, ProcessFormError, eid_param, \
55       formwidgets as fw
56  from cubicweb.web.views import uicfg
57 
@@ -1193,14 +1193,17 @@
58              if rdef.get('internationalizable'):
59                  kwargs.setdefault('internationalizable', True)
60      else:
61          targetschema = rdef.subject
62      card = rdef.role_cardinality(role)
63 +    composite = getattr(rdef, 'composite', None)
64      kwargs['name'] = rschema.type
65      kwargs['role'] = role
66      kwargs['eidparam'] = True
67 -    kwargs.setdefault('required', card in '1+')
68 +    # don't mark composite relation as required, we want the composite element
69 +    # to be removed when not linked to its parent
70 +    kwargs.setdefault('required', card in '1+' and composite != neg_role(role))
71      if role == 'object':
72          kwargs.setdefault('label', (eschema.type, rschema.type + '_object'))
73      else:
74          kwargs.setdefault('label', (eschema.type, rschema.type))
75      kwargs.setdefault('help', rdef.description)
diff --git a/web/test/data/schema.py b/web/test/data/schema.py
@@ -90,8 +90,35 @@
76      cardinality = '?*'
77 
78  class Ticket(EntityType):
79      title = String(maxsize=32, required=True, fulltextindexed=True)
80      concerns = SubjectRelation('Project', composite='object')
81 +    in_version = SubjectRelation('Version', composite='object',
82 +                                 cardinality='?*', inlined=True)
83 +
84 +class Version(EntityType):
85 +    name = String(required=True)
86 +
87 +class Filesystem(EntityType):
88 +    name = String()
89 +
90 +class DirectoryPermission(EntityType):
91 +    value = String()
92 +
93 +class parent_fs(RelationDefinition):
94 +    name = 'parent'
95 +    subject = 'Directory'
96 +    object = 'Filesystem'
97 +
98 +class Directory(EntityType):
99 +    name = String(required=True)
100 +    has_permission = SubjectRelation('DirectoryPermission', cardinality='*1',
101 +                                     composite='subject')
102 +
103 +class parent_directory(RelationDefinition):
104 +    name = 'parent'
105 +    subject = 'Directory'
106 +    object = 'Directory'
107 +    composite = 'object'
108 
109  # used by windmill for `test_edit_relation`
110  from cubes.folder.schema import Folder
diff --git a/web/test/unittest_application.py b/web/test/unittest_application.py
@@ -21,11 +21,10 @@
111  import httplib
112 
113  from logilab.common.testlib import TestCase, unittest_main
114  from logilab.common.decorators import clear_cache, classproperty
115 
116 -from cubicweb import AuthenticationError
117  from cubicweb import view
118  from cubicweb.devtools.testlib import CubicWebTC, real_error_handling
119  from cubicweb.devtools.fake import FakeRequest
120  from cubicweb.web import LogOut, Redirect, INTERNAL_FIELD_VALUE
121  from cubicweb.web.views.basecontrollers import ViewController
@@ -256,10 +255,263 @@
122              self.assertEqual(forminfo['error'].entity, forminfo['eidmap']['X'])
123              self.assertEqual(forminfo['error'].errors,
124                                {'login-subject': u'the value "admin" is already used, use another one'})
125              self.assertEqual(forminfo['values'], req.form)
126 
127 +    def _edit_parent(self, dir_eid, parent_eid, role='subject',
128 +                     etype='Directory', **kwargs):
129 +        parent_eid = parent_eid or '__cubicweb_internal_field__'
130 +        with self.admin_access.web_request() as req:
131 +            req.form = {
132 +                'eid': unicode(dir_eid),
133 +                '__maineid': unicode(dir_eid),
134 +                '__type:%s' % dir_eid: etype,
135 +                'parent-%s:%s' % (role, dir_eid): parent_eid,
136 +            }
137 +            req.form.update(kwargs)
138 +            req.form['_cw_entity_fields:%s' % dir_eid] = ','.join(
139 +                ['parent-%s' % role] +
140 +                [key.split(':')[0]
141 +                 for key in kwargs.keys()
142 +                 if not key.startswith('_')])
143 +            self.expect_redirect_handle_request(req)
144 +
145 +    def _edit_in_version(self, ticket_eid, version_eid, **kwargs):
146 +        version_eid = version_eid or '__cubicweb_internal_field__'
147 +        with self.admin_access.web_request() as req:
148 +            req.form = {
149 +                'eid': unicode(ticket_eid),
150 +                '__maineid': unicode(ticket_eid),
151 +                '__type:%s' % ticket_eid: 'Ticket',
152 +                'in_version-subject:%s' % ticket_eid: version_eid,
153 +            }
154 +            req.form.update(kwargs)
155 +            req.form['_cw_entity_fields:%s' % ticket_eid] = ','.join(
156 +                ['in_version-subject'] +
157 +                [key.split(':')[0]
158 +                 for key in kwargs.keys()
159 +                 if not key.startswith('_')])
160 +            self.expect_redirect_handle_request(req)
161 +
162 +    def test_create_and_link_directories(self):
163 +        with self.admin_access.web_request() as req:
164 +            req.form = {
165 +                'eid': (u'A', u'B'),
166 +                '__maineid': u'A',
167 +                '__type:A': 'Directory',
168 +                '__type:B': 'Directory',
169 +                'parent-subject:B': u'A',
170 +                'name-subject:A': u'topd',
171 +                'name-subject:B': u'subd',
172 +                '_cw_entity_fields:A': 'name-subject',
173 +                '_cw_entity_fields:B': 'parent-subject,name-subject',
174 +            }
175 +            self.expect_redirect_handle_request(req)
176 +
177 +        with self.admin_access.repo_cnx() as cnx:
178 +            self.assertTrue(cnx.find('Directory', name=u'topd'))
179 +            self.assertTrue(cnx.find('Directory', name=u'subd'))
180 +            self.assertEqual(1, cnx.execute(
181 +                'Directory SUBD WHERE SUBD parent TOPD,'
182 +                ' SUBD name "subd", TOPD name "topd"').rowcount)
183 +
184 +    def test_create_subentity(self):
185 +        with self.admin_access.repo_cnx() as cnx:
186 +            topd = cnx.create_entity('Directory', name=u'topd')
187 +            cnx.commit()
188 +
189 +        with self.admin_access.web_request() as req:
190 +            req.form = {
191 +                'eid': (unicode(topd.eid), u'B'),
192 +                '__maineid': unicode(topd.eid),
193 +                '__type:%s' % topd.eid: 'Directory',
194 +                '__type:B': 'Directory',
195 +                'parent-object:%s' % topd.eid: u'B',
196 +                'name-subject:B': u'subd',
197 +                '_cw_entity_fields:%s' % topd.eid: 'parent-object',
198 +                '_cw_entity_fields:B': 'name-subject',
199 +            }
200 +            self.expect_redirect_handle_request(req)
201 +
202 +        with self.admin_access.repo_cnx() as cnx:
203 +            self.assertTrue(cnx.find('Directory', name=u'topd'))
204 +            self.assertTrue(cnx.find('Directory', name=u'subd'))
205 +            self.assertEqual(1, cnx.execute(
206 +                'Directory SUBD WHERE SUBD parent TOPD,'
207 +                ' SUBD name "subd", TOPD name "topd"').rowcount)
208 +
209 +    def test_subject_subentity_removal(self):
210 +        """Editcontroller: detaching a composite relation removes the subentity
211 +        (edit from the subject side)
212 +        """
213 +        with self.admin_access.repo_cnx() as cnx:
214 +            topd = cnx.create_entity('Directory', name=u'topd')
215 +            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
216 +            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
217 +            cnx.commit()
218 +
219 +        attrs = {'name-subject:%s' % sub1.eid: ''}
220 +        self._edit_parent(sub1.eid, parent_eid=None, **attrs)
221 +
222 +        with self.admin_access.repo_cnx() as cnx:
223 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
224 +            self.assertFalse(cnx.find('Directory', eid=sub1.eid))
225 +            self.assertTrue(cnx.find('Directory', eid=sub2.eid))
226 +
227 +    def test_object_subentity_removal(self):
228 +        """Editcontroller: detaching a composite relation removes the subentity
229 +        (edit from the object side)
230 +        """
231 +        with self.admin_access.repo_cnx() as cnx:
232 +            topd = cnx.create_entity('Directory', name=u'topd')
233 +            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
234 +            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
235 +            cnx.commit()
236 +
237 +        self._edit_parent(topd.eid, parent_eid=sub1.eid, role='object')
238 +
239 +        with self.admin_access.repo_cnx() as cnx:
240 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
241 +            self.assertTrue(cnx.find('Directory', eid=sub1.eid))
242 +            self.assertFalse(cnx.find('Directory', eid=sub2.eid))
243 +
244 +    def test_reparent_subentity(self):
245 +        "Editcontroller: re-parenting a subentity does not remove it"
246 +        with self.admin_access.repo_cnx() as cnx:
247 +            top1 = cnx.create_entity('Directory', name=u'top1')
248 +            top2 = cnx.create_entity('Directory', name=u'top2')
249 +            subd = cnx.create_entity('Directory', name=u'subd', parent=top1)
250 +            cnx.commit()
251 +
252 +        self._edit_parent(subd.eid, parent_eid=top2.eid)
253 +
254 +        with self.admin_access.repo_cnx() as cnx:
255 +            self.assertTrue(cnx.find('Directory', eid=top1.eid))
256 +            self.assertTrue(cnx.find('Directory', eid=top2.eid))
257 +            self.assertTrue(cnx.find('Directory', eid=subd.eid))
258 +            self.assertEqual(
259 +                cnx.find('Directory', eid=subd.eid).one().parent[0], top2)
260 +
261 +    def test_reparent_subentity_inlined(self):
262 +        """Editcontroller: re-parenting a subentity does not remove it
263 +        (inlined case)"""
264 +        with self.admin_access.repo_cnx() as cnx:
265 +            version1 = cnx.create_entity('Version', name=u'version1')
266 +            version2 = cnx.create_entity('Version', name=u'version2')
267 +            ticket = cnx.create_entity('Ticket', title=u'ticket',
268 +                                       in_version=version1)
269 +            cnx.commit()
270 +
271 +        self._edit_in_version(ticket.eid, version_eid=version2.eid)
272 +
273 +        with self.admin_access.repo_cnx() as cnx:
274 +            self.assertTrue(cnx.find('Version', eid=version1.eid))
275 +            self.assertTrue(cnx.find('Version', eid=version2.eid))
276 +            self.assertTrue(cnx.find('Ticket', eid=ticket.eid))
277 +            self.assertEqual(
278 +                cnx.find('Ticket', eid=ticket.eid).one().in_version[0], version2)
279 +
280 +    def test_subject_mixed_composite_subentity_removal_1(self):
281 +        """Editcontroller: detaching several subentities respects each rdef's
282 +        compositeness - Remove non composite
283 +        """
284 +        with self.admin_access.repo_cnx() as cnx:
285 +            topd = cnx.create_entity('Directory', name=u'topd')
286 +            fs = cnx.create_entity('Filesystem', name=u'/tmp')
287 +            subd = cnx.create_entity('Directory', name=u'subd',
288 +                                     parent=(topd, fs))
289 +            cnx.commit()
290 +
291 +        self._edit_parent(subd.eid, parent_eid=topd.eid)
292 +
293 +        with self.admin_access.repo_cnx() as cnx:
294 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
295 +            self.assertTrue(cnx.find('Directory', eid=subd.eid))
296 +            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
297 +            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
298 +                             [topd,])
299 +
300 +    def test_subject_mixed_composite_subentity_removal_2(self):
301 +        """Editcontroller: detaching several subentities respects each rdef's
302 +        compositeness - Remove composite
303 +        """
304 +        with self.admin_access.repo_cnx() as cnx:
305 +            topd = cnx.create_entity('Directory', name=u'topd')
306 +            fs = cnx.create_entity('Filesystem', name=u'/tmp')
307 +            subd = cnx.create_entity('Directory', name=u'subd',
308 +                                     parent=(topd, fs))
309 +            cnx.commit()
310 +
311 +        self._edit_parent(subd.eid, parent_eid=fs.eid)
312 +
313 +        with self.admin_access.repo_cnx() as cnx:
314 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
315 +            self.assertFalse(cnx.find('Directory', eid=subd.eid))
316 +            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
317 +
318 +    def test_object_mixed_composite_subentity_removal_1(self):
319 +        """Editcontroller: detaching several subentities respects each rdef's
320 +        compositeness - Remove non composite
321 +        """
322 +        with self.admin_access.repo_cnx() as cnx:
323 +            topd = cnx.create_entity('Directory', name=u'topd')
324 +            fs = cnx.create_entity('Filesystem', name=u'/tmp')
325 +            subd = cnx.create_entity('Directory', name=u'subd',
326 +                                     parent=(topd, fs))
327 +            cnx.commit()
328 +
329 +        self._edit_parent(fs.eid, parent_eid=None, role='object',
330 +                          etype='Filesystem')
331 +
332 +        with self.admin_access.repo_cnx() as cnx:
333 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
334 +            self.assertTrue(cnx.find('Directory', eid=subd.eid))
335 +            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
336 +            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
337 +                             [topd,])
338 +
339 +    def test_object_mixed_composite_subentity_removal_2(self):
340 +        """Editcontroller: detaching several subentities respects each rdef's
341 +        compositeness - Remove composite
342 +        """
343 +        with self.admin_access.repo_cnx() as cnx:
344 +            topd = cnx.create_entity('Directory', name=u'topd')
345 +            fs = cnx.create_entity('Filesystem', name=u'/tmp')
346 +            subd = cnx.create_entity('Directory', name=u'subd',
347 +                                     parent=(topd, fs))
348 +            cnx.commit()
349 +
350 +        self._edit_parent(topd.eid, parent_eid=None, role='object')
351 +
352 +        with self.admin_access.repo_cnx() as cnx:
353 +            self.assertTrue(cnx.find('Directory', eid=topd.eid))
354 +            self.assertFalse(cnx.find('Directory', eid=subd.eid))
355 +            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
356 +
357 +    def test_delete_mandatory_composite(self):
358 +        with self.admin_access.repo_cnx() as cnx:
359 +            perm = cnx.create_entity('DirectoryPermission')
360 +            mydir = cnx.create_entity('Directory', name=u'dir',
361 +                                      has_permission=perm)
362 +            cnx.commit()
363 +
364 +        with self.admin_access.web_request() as req:
365 +            dir_eid = unicode(mydir.eid)
366 +            perm_eid = unicode(perm.eid)
367 +            req.form = {
368 +                'eid': [dir_eid, perm_eid],
369 +                '__maineid' : dir_eid,
370 +                '__type:%s' % dir_eid: 'Directory',
371 +                '__type:%s' % perm_eid: 'DirectoryPermission',
372 +                '_cw_entity_fields:%s' % dir_eid: '',
373 +                '_cw_entity_fields:%s' % perm_eid: 'has_permission-object',
374 +                'has_permission-object:%s' % perm_eid: '',
375 +                }
376 +            path, _params = self.expect_redirect_handle_request(req, 'edit')
377 +            self.assertTrue(req.find('Directory', eid=mydir.eid))
378 +            self.assertFalse(req.find('DirectoryPermission', eid=perm.eid))
379 +
380      def test_ajax_view_raise_arbitrary_error(self):
381          class ErrorAjaxView(view.View):
382              __regid__ = 'test.ajax.error'
383              def call(self):
384                  raise Exception('whatever')
diff --git a/web/views/editcontroller.py b/web/views/editcontroller.py
@@ -73,25 +73,28 @@
385  class RqlQuery(object):
386      def __init__(self):
387          self.edited = []
388          self.restrictions = []
389          self.kwargs = {}
390 +        self.canceled = False
391 
392      def __repr__(self):
393          return ('Query <edited=%r restrictions=%r kwargs=%r>' % (
394              self.edited, self.restrictions, self.kwargs))
395 
396      def insert_query(self, etype):
397 +        assert not self.canceled
398          if self.edited:
399              rql = 'INSERT %s X: %s' % (etype, ','.join(self.edited))
400          else:
401              rql = 'INSERT %s X' % etype
402          if self.restrictions:
403              rql += ' WHERE %s' % ','.join(self.restrictions)
404          return rql
405 
406      def update_query(self, eid):
407 +        assert not self.canceled
408          varmaker = rqlvar_maker()
409          var = varmaker.next()
410          while var in self.kwargs:
411              var = varmaker.next()
412          rql = 'SET %s WHERE X eid %%(%s)s' % (','.join(self.edited), var)
@@ -184,10 +187,11 @@
413          # those two data variables are used to handle relation from/to entities
414          # which doesn't exist at time where the entity is edited and that
415          # deserves special treatment
416          req.data['pending_inlined'] = defaultdict(set)
417          req.data['pending_others'] = set()
418 +        req.data['pending_composite_delete'] = set()
419          try:
420              for formparams in self._ordered_formparams():
421                  eid = self.edit_entity(formparams)
422          except (RequestError, NothingToEdit) as ex:
423              if '__linkto' in req.form and 'eid' in req.form:
@@ -202,10 +206,13 @@
424          for form_, field in req.data.pop('pending_others'):
425              self.handle_formfield(form_, field)
426          # then execute rql to set all relations
427          for querydef in self.relations_rql:
428              self._cw.execute(*querydef)
429 +        # delete pending composite
430 +        for entity in req.data['pending_composite_delete']:
431 +            entity.cw_delete()
432          # XXX this processes *all* pending operations of *all* entities
433          if '__delete' in req.form:
434              todelete = req.list_form_param('__delete', req.form, pop=True)
435              if todelete:
436                  autoform.delete_relations(self._cw, todelete)
@@ -258,17 +265,20 @@
437              self.handle_formfield(form, field, rqlquery)
438          # if there are some inlined field which were waiting for this entity's
439          # creation, add relevant data to the rqlquery
440          for form_, field in req.data['pending_inlined'].pop(entity.eid, ()):
441              rqlquery.set_inlined(field.name, form_.edited_entity.eid)
442 -        if self.errors:
443 -            errors = dict((f.role_name(), unicode(ex)) for f, ex in self.errors)
444 -            raise ValidationError(valerror_eid(entity.eid), errors)
445 -        if eid is None: # creation or copy
446 -            entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
447 -        elif rqlquery.edited: # edition of an existant entity
448 -            self._update_entity(eid, rqlquery)
449 +        if not rqlquery.canceled:
450 +            if self.errors:
451 +                errors = dict((f.role_name(), unicode(ex)) for f, ex in self.errors)
452 +                raise ValidationError(valerror_eid(entity.eid), errors)
453 +            if eid is None: # creation or copy
454 +                entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
455 +            elif rqlquery.edited: # edition of an existant entity
456 +                self._update_entity(eid, rqlquery)
457 +        else:
458 +            self.errors = []
459          if is_main_entity:
460              self.notify_edited(entity)
461          if '__delete' in formparams:
462              # XXX deprecate?
463              todelete = req.list_form_param('__delete', formparams, pop=True)
@@ -278,37 +288,82 @@
464          if is_main_entity: # only execute linkto for the main entity
465              self.execute_linkto(entity.eid)
466          return eid
467 
468      def handle_formfield(self, form, field, rqlquery=None):
469 -        eschema = form.edited_entity.e_schema
470 +        entity = form.edited_entity
471 +        eschema = entity.e_schema
472          try:
473              for field, value in field.process_posted(form):
474                  if not (
475                      (field.role == 'subject' and field.name in eschema.subjrels)
476                      or
477                      (field.role == 'object' and field.name in eschema.objrels)):
478                      continue
479 +
480                  rschema = self._cw.vreg.schema.rschema(field.name)
481                  if rschema.final:
482                      rqlquery.set_attribute(field.name, value)
483 +                    continue
484 +
485 +                if entity.has_eid():
486 +                    origvalues = set(data[0] for data in entity.related(field.name, field.role).rows)
487                  else:
488 -                    if form.edited_entity.has_eid():
489 -                        origvalues = set(entity.eid for entity in form.edited_entity.related(field.name, field.role, entities=True))
490 -                    else:
491 -                        origvalues = set()
492 -                    if value is None or value == origvalues:
493 -                        continue # not edited / not modified / to do later
494 -                    if rschema.inlined and rqlquery is not None and field.role == 'subject':
495 -                        self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
496 -                    elif form.edited_entity.has_eid():
497 -                        self.handle_relation(form, field, value, origvalues)
498 -                    else:
499 -                        form._cw.data['pending_others'].add( (form, field) )
500 +                    origvalues = set()
501 +                if value is None or value == origvalues:
502 +                    continue # not edited / not modified / to do later
503 +
504 +                unlinked_eids = origvalues - value
505 +
506 +                if unlinked_eids:
507 +                    # Special handling of composite relation removal
508 +                    self.handle_composite_removal(
509 +                        form, field, unlinked_eids, value, rqlquery)
510 +
511 +                if rschema.inlined and rqlquery is not None and field.role == 'subject':
512 +                    self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
513 +                elif form.edited_entity.has_eid():
514 +                    self.handle_relation(form, field, value, origvalues)
515 +                else:
516 +                    form._cw.data['pending_others'].add( (form, field) )
517 +
518          except ProcessFormError as exc:
519              self.errors.append((field, exc))
520 
521 +    def handle_composite_removal(self, form, field,
522 +                                 removed_values, new_values, rqlquery):
523 +        """
524 +        In EditController-handled forms, when the user removes a composite
525 +        relation, it triggers the removal of the related entity in the
526 +        composite. This is where this happens.
527 +
528 +        See for instance test_subject_subentity_removal in
529 +        web/test/unittest_application.py.
530 +        """
531 +        rschema = self._cw.vreg.schema.rschema(field.name)
532 +        new_value_etypes = set(self._cw.entity_from_eid(eid).cw_etype
533 +                               for eid in new_values)
534 +        for unlinked_eid in removed_values:
535 +            unlinked_entity = self._cw.entity_from_eid(unlinked_eid)
536 +            rdef = rschema.role_rdef(form.edited_entity.cw_etype,
537 +                                     unlinked_entity.cw_etype,
538 +                                     field.role)
539 +            if rdef.composite is not None:
540 +                if rdef.composite == field.role:
541 +                    to_be_removed = unlinked_entity
542 +                else:
543 +                    if unlinked_entity.cw_etype in new_value_etypes:
544 +                        # This is a same-rdef re-parenting: do not remove the entity
545 +                        continue
546 +                    to_be_removed = form.edited_entity
547 +                    self.info('Edition of %s is cancelled (deletion requested)',
548 +                              to_be_removed)
549 +                    rqlquery.canceled = True
550 +                self.info('Scheduling removal of %s as composite relation '
551 +                          '%s was removed', to_be_removed, rdef)
552 +                form._cw.data['pending_composite_delete'].add(to_be_removed)
553 +
554      def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
555          """handle edition for the (rschema, x) relation of the given entity
556          """
557          if values:
558              rqlquery.set_inlined(field.name, iter(values).next())