merge 3.19 heads
Getting an unexpected test error though:
ERROR: test_edit_mandatory_inlined3_object (unittest_views_basecontrollers.EditControllerTC)
--- a/server/sources/native.py Mon Jul 27 10:00:32 2015 +0200
+++ b/server/sources/native.py Wed Feb 03 15:12:32 2016 +0100
@@ -40,7 +40,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 import schema2sql as y2sql
@@ -1708,15 +1708,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/web/formfields.py Mon Jul 27 10:00:32 2015 +0200
+++ b/web/formfields.py Wed Feb 03 15:12:32 2016 +0100
@@ -76,7 +76,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
@@ -1195,10 +1195,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/web/test/data/schema.py Mon Jul 27 10:00:32 2015 +0200
+++ b/web/test/data/schema.py Wed Feb 03 15:12:32 2016 +0100
@@ -92,6 +92,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'
# used by windmill for `test_edit_relation`
from cubes.folder.schema import Folder
--- a/web/test/unittest_application.py Mon Jul 27 10:00:32 2015 +0200
+++ b/web/test/unittest_application.py Wed Feb 03 15:12:32 2016 +0100
@@ -23,7 +23,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
@@ -258,6 +257,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/web/views/editcontroller.py Mon Jul 27 10:00:32 2015 +0200
+++ b/web/views/editcontroller.py Wed Feb 03 15:12:32 2016 +0100
@@ -75,12 +75,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:
@@ -90,6 +92,7 @@
return rql
def update_query(self, eid):
+ assert not self.canceled
varmaker = rqlvar_maker()
var = varmaker.next()
while var in self.kwargs:
@@ -186,6 +189,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)
@@ -204,6 +208,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)
@@ -260,13 +267,16 @@
# 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(), unicode(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._update_entity(eid, rqlquery)
+ if not rqlquery.canceled:
+ if self.errors:
+ errors = dict((f.role_name(), unicode(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._update_entity(eid, rqlquery)
+ else:
+ self.errors = []
if is_main_entity:
self.notify_edited(entity)
if '__delete' in formparams:
@@ -280,7 +290,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 (
@@ -288,25 +299,69 @@
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(self._cw.entity_from_eid(eid).cw_etype
+ for eid in new_values)
+ 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
"""