Commit b1a20441 authored by Denis Laxalde's avatar Denis Laxalde
Browse files

Merge with 3.25

......@@ -608,3 +608,9 @@ dacc5b168e29b33515cee5940de1e392dc9d522a centos/3.25.0-1
5010381099f1227724261665f0843a60447991b2 3.25.2
5010381099f1227724261665f0843a60447991b2 debian/3.25.2-1
5010381099f1227724261665f0843a60447991b2 centos/3.25.2-1
d238badfc268ad4440b3238a24690858bad3fbdd 3.25.3
d238badfc268ad4440b3238a24690858bad3fbdd centos/3.25.3-1
d238badfc268ad4440b3238a24690858bad3fbdd debian/3.25.3-1
b8567725c473b701fe9352e578ad6e05c523c1f2 3.25.4
b8567725c473b701fe9352e578ad6e05c523c1f2 centos/3.25.4-1
b8567725c473b701fe9352e578ad6e05c523c1f2 debian/3.25.4-1
......@@ -8,7 +8,7 @@
%{!?python_sitelib: %define python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print get_python_lib()")}
Name: cubicweb
Version: 3.25.2
Version: 3.25.4
Release: logilab.1%{?dist}
Summary: CubicWeb is a semantic web application framework
Source0: https://pypi.python.org/packages/source/c/cubicweb/cubicweb-%{version}.tar.gz
......@@ -29,6 +29,7 @@ Requires: %{python}-yams >= 0.45.0
Requires: %{python}-logilab-database >= 1.15.0
Requires: %{python}-passlib
Requires: %{python}-lxml
Requires: %{python}-unittest2 >= 0.7.0
Requires: %{python}-twisted-web < 16.0.0
Requires: %{python}-markdown
Requires: pytz
......
......@@ -98,15 +98,29 @@ class AnyEntity(Entity):
# meta data api ###########################################################
def __getattr__(self, name):
prefix = 'dc_'
if name.startswith(prefix):
# Proxy to IDublinCore adapter for bw compat.
adapted = self.cw_adapt_to('IDublinCore')
method = name[len(prefix):]
if hasattr(adapted, method):
return getattr(adapted, method)
raise AttributeError(name)
def dc_title(self):
return self.cw_adapt_to('IDublinCore').title()
def dc_long_title(self):
return self.cw_adapt_to('IDublinCore').long_title()
def dc_description(self, *args, **kwargs):
return self.cw_adapt_to('IDublinCore').description(*args, **kwargs)
def dc_authors(self):
return self.cw_adapt_to('IDublinCore').authors()
def dc_creator(self):
return self.cw_adapt_to('IDublinCore').creator()
def dc_date(self, *args, **kwargs):
return self.cw_adapt_to('IDublinCore').date(*args, **kwargs)
def dc_type(self, *args, **kwargs):
return self.cw_adapt_to('IDublinCore').type(*args, **kwargs)
def dc_language(self):
return self.cw_adapt_to('IDublinCore').language()
@property
def creator(self):
......
......@@ -350,13 +350,15 @@ class PyramidStartHandler(InstanceCommand):
host = cwconfig['interface']
port = cwconfig['port'] or 8080
url_scheme = ('https' if cwconfig['base-url'].startswith('https')
else 'http')
repo = app.application.registry['cubicweb.repository']
warnings.warn(
'the "pyramid" command does not start repository "looping tasks" '
'anymore; use the standalone "scheduler" command if needed'
)
try:
waitress.serve(app, host=host, port=port)
waitress.serve(app, host=host, port=port, url_scheme=url_scheme)
finally:
repo.shutdown()
if self._needreload:
......
......@@ -88,7 +88,14 @@ class RelationTags(RegistrableRtags):
def __repr__(self):
# find a way to have more infos but keep it readable
# (in error messages in case of an ambiguity for instance)
return '%s (%s): %s' % (id(self), self.__regid__, self.__class__)
return '<%s %s>' % (self.__regid__, self._short_repr())
def _short_repr(self):
# find a way to have more infos but keep it readable
# (in error messages in case of an ambiguity for instance)
return '%s@0x%x%s' % (
self.__module__, id(self),
' derived from %s' % self._parent._short_repr() if self._parent else '')
# dict compat
def __getitem__(self, key):
......
# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# copyright 2003 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
......@@ -19,9 +19,8 @@
from __future__ import print_function
import pkgutil
import re
from os.path import join, basename
from os.path import join
from hashlib import md5
from logging import getLogger
from warnings import warn
......@@ -29,7 +28,6 @@ from warnings import warn
from six import PY2, text_type, string_types, add_metaclass
from six.moves import range
from logilab.common import tempattr
from logilab.common.decorators import cached, clear_cache, monkeypatch, cachedproperty
from logilab.common.logging_ext import set_log_methods
from logilab.common.deprecation import deprecated
......@@ -44,14 +42,15 @@ from yams.constraints import (BaseConstraint, FormatConstraint,
cstr_json_dumps, cstr_json_loads)
from yams.reader import (CONSTRAINTS, PyFileReader, SchemaLoader,
cleanup_sys_modules, fill_schema_from_namespace)
from yams.buildobjs import _add_relation as yams_add_relation
from rql import parse, nodes, RQLSyntaxError, TypeResolverException
from rql import parse, nodes, stmts, RQLSyntaxError, TypeResolverException
from rql.analyze import ETypeResolver
import cubicweb
from cubicweb import server
from cubicweb import ETYPE_NAME_MAP, ValidationError, Unauthorized, _
from cubicweb import server
PURE_VIRTUAL_RTYPES = set(('identity', 'has_text',))
VIRTUAL_RTYPES = set(('eid', 'identity', 'has_text',))
......@@ -602,6 +601,8 @@ def ERSchema_display_name(self, req, form='', context=None):
a given form
"""
return display_name(req, self.type, form, context)
ERSchema.display_name = ERSchema_display_name
......@@ -621,6 +622,8 @@ def get_groups(self, action):
return frozenset(g for g in self.permissions[action] if isinstance(g, string_types))
except KeyError:
return ()
PermissionMixIn.get_groups = get_groups
......@@ -640,6 +643,8 @@ def get_rqlexprs(self, action):
return tuple(g for g in self.permissions[action] if not isinstance(g, string_types))
except KeyError:
return ()
PermissionMixIn.get_rqlexprs = get_rqlexprs
......@@ -656,6 +661,8 @@ def set_action_permissions(self, action, permissions):
orig_set_action_permissions(self, action, tuple(permissions))
clear_cache(self, 'get_rqlexprs')
clear_cache(self, 'get_groups')
orig_set_action_permissions = PermissionMixIn.set_action_permissions
PermissionMixIn.set_action_permissions = set_action_permissions
......@@ -673,6 +680,8 @@ def has_local_role(self, action):
if action in ('update', 'delete'):
return 'owners' in self.get_groups(action)
return False
PermissionMixIn.has_local_role = has_local_role
......@@ -681,6 +690,8 @@ def may_have_permission(self, action, req):
self.has_perm(req, 'read')):
return False
return self.has_local_role(action) or self.has_perm(req, action)
PermissionMixIn.may_have_permission = may_have_permission
......@@ -691,6 +702,8 @@ def has_perm(self, _cw, action, **kwargs):
return True
except Unauthorized:
return False
PermissionMixIn.has_perm = has_perm
......@@ -734,6 +747,8 @@ def check_perm(self, _cw, action, **kwargs):
for rqlexpr in self.get_rqlexprs(action)):
return
raise Unauthorized(action, str(self))
PermissionMixIn.check_perm = check_perm
......@@ -818,7 +833,7 @@ class CubicWebEntitySchema(EntitySchema):
"""convenience method that returns the *main* (i.e. the first non meta)
attribute defined in the entity schema
"""
for rschema, _ in self.attribute_definitions():
for rschema, __ in self.attribute_definitions():
if not (rschema in META_RTYPES
or self.is_metadata(rschema)):
return rschema
......@@ -1262,7 +1277,7 @@ class RepoEnforcedRQLConstraintMixIn(object):
#
# possible enhancement: check entity being created, it's probably
# the main eid unless this is a composite relation
if eidto is None or 'S' in self.mainvars or not 'O' in self.mainvars:
if eidto is None or 'S' in self.mainvars or 'O' not in self.mainvars:
maineid = eidfrom
qname = role_name(rtype, 'subject')
else:
......@@ -1272,7 +1287,7 @@ class RepoEnforcedRQLConstraintMixIn(object):
msg = session._(self.msg)
else:
msg = '%(constraint)s %(expression)s failed' % {
'constraint': session._(self.type()),
'constraint': session._(self.type()),
'expression': self.expression}
raise ValidationError(maineid, {qname: msg})
......@@ -1320,9 +1335,6 @@ class RQLUniqueConstraint(RepoEnforcedRQLConstraintMixIn, BaseRQLConstraint):
# workflow extensions #########################################################
from yams.buildobjs import _add_relation as yams_add_relation
class workflowable_definition(ybo.metadefinition):
"""extends default EntityType's metaclass to add workflow relations
(i.e. in_state, wf_info_for and custom_workflow). This is the default
......@@ -1409,7 +1421,8 @@ class CubicWebSchemaLoader(BootstrapSchemaLoader):
"""
self.info('loading %s schemas', ', '.join(config.cubes()))
try:
return super(CubicWebSchemaLoader, self).load(config, config.schema_modnames(), **kwargs)
return super(CubicWebSchemaLoader, self).load(
config, config.schema_modnames(), **kwargs)
finally:
# we've to cleanup modules imported from cubicweb.schemas as well
cleanup_sys_modules([join(cubicweb.CW_SOFTWARE_ROOT, 'schemas')])
......@@ -1448,29 +1461,36 @@ def vocabulary(self, entity=None, form=None):
return self.regular_formats + tuple(NEED_PERM_FORMATS)
return self.regular_formats
# XXX itou for some Statement methods
from rql import stmts
# XXX itou for some Statement methods
def bw_get_etype(self, name):
return orig_get_etype(self, bw_normalize_etype(name))
orig_get_etype = stmts.ScopeNode.get_etype
stmts.ScopeNode.get_etype = bw_get_etype
def bw_add_main_variable_delete(self, etype, vref):
return orig_add_main_variable_delete(self, bw_normalize_etype(etype), vref)
orig_add_main_variable_delete = stmts.Delete.add_main_variable
stmts.Delete.add_main_variable = bw_add_main_variable_delete
def bw_add_main_variable_insert(self, etype, vref):
return orig_add_main_variable_insert(self, bw_normalize_etype(etype), vref)
orig_add_main_variable_insert = stmts.Insert.add_main_variable
stmts.Insert.add_main_variable = bw_add_main_variable_insert
def bw_set_statement_type(self, etype):
return orig_set_statement_type(self, bw_normalize_etype(etype))
orig_set_statement_type = stmts.Select.set_statement_type
stmts.Select.set_statement_type = bw_set_statement_type
......@@ -24,7 +24,7 @@ from rql.nodes import Constant, Relation
from cubicweb import QueryError
from cubicweb.schema import VIRTUAL_RTYPES
from cubicweb.rqlrewrite import add_types_restriction
from cubicweb.rqlrewrite import add_types_restriction, RQLRelationRewriter
from cubicweb.server.edition import EditedEntity
READ_ONLY_RTYPES = set(('eid', 'has_text', 'is', 'is_instance_of', 'identity'))
......@@ -302,6 +302,9 @@ class SSPlanner(object):
union.append(select)
select.clean_solutions(solutions)
add_types_restriction(self.schema, select)
# Rewrite computed relations
rewriter = RQLRelationRewriter(plan.cnx)
rewriter.rewrite(union, plan.args)
self.rqlhelper.annotate(union)
return self.build_select_plan(plan, union)
......
......@@ -1404,6 +1404,16 @@ Any P1,B,E WHERE P1 identity P2 WITH
self.assertEqual(len(rset.rows), 1)
self.assertEqual(rset.description, [('CWUser',)])
# computed relation tests ##################################################
def test_computed_relation_write_queries(self):
"""Ensure we can use computed relation in WHERE clause of write queries"""
with self.admin_access.cnx() as cnx:
cnx.execute('INSERT Personne P: P nom "user", P login_user U WHERE NOT U user_login P')
cnx.execute('DELETE P login_user U WHERE U user_login P')
cnx.execute('DELETE Personne P WHERE U user_login P')
cnx.execute('SET U login "people" WHERE U user_login P')
# ZT datetime tests ########################################################
def test_tz_datetime(self):
......
......@@ -79,11 +79,11 @@ def anonymized_request(req):
from cubicweb.web.views.authentication import Session
orig_cnx = req.cnx
anon_cnx = anonymous_cnx(orig_cnx.session.repo)
anon_cnx = anonymous_cnx(orig_cnx.repo)
try:
with anon_cnx:
# web request expect a session attribute on cnx referencing the web session
anon_cnx.session = Session(orig_cnx.session.repo, anon_cnx.user)
anon_cnx.session = Session(orig_cnx.repo, anon_cnx.user)
req.set_cnx(anon_cnx)
yield req
finally:
......
# copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# copyright 2003 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
......@@ -15,23 +15,26 @@
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb. If not, see <http://www.gnu.org/licenses/>.
import copy
import warnings
from logilab.common.testlib import tag
from cubicweb.devtools.testlib import CubicWebTC
from yams.buildobjs import RelationDefinition, EntityType
from cubicweb.devtools.testlib import CubicWebTC, BaseTestCase
from cubicweb.schema import build_schema_from_namespace
from cubicweb.web import uihelper, formwidgets as fwdgs
from cubicweb.web.views import uicfg
abaa = uicfg.actionbox_appearsin_addmenu
class UICFGTC(CubicWebTC):
def test_default_actionbox_appearsin_addmenu_config(self):
self.assertFalse(abaa.etype_get('TrInfo', 'wf_info_for', 'object', 'CWUser'))
class DefinitionOrderTC(CubicWebTC):
"""This test check that when multiple definition could match a key, only
the more accurate apply"""
......@@ -41,19 +44,19 @@ class DefinitionOrderTC(CubicWebTC):
for rtag in (uicfg.autoform_section, uicfg.autoform_field_kwargs):
rtag._old_tagdefs = copy.deepcopy(rtag._tagdefs)
new_def = (
(('*', 'login', '*'),
{'formtype':'main', 'section':'hidden'}),
(('*', 'login', '*'),
{'formtype':'muledit', 'section':'hidden'}),
(('CWUser', 'login', '*'),
{'formtype':'main', 'section':'attributes'}),
(('CWUser', 'login', '*'),
{'formtype':'muledit', 'section':'attributes'}),
(('CWUser', 'login', 'String'),
{'formtype':'main', 'section':'inlined'}),
(('CWUser', 'login', 'String'),
{'formtype':'inlined', 'section':'attributes'}),
)
(('*', 'login', '*'),
{'formtype': 'main', 'section': 'hidden'}),
(('*', 'login', '*'),
{'formtype': 'muledit', 'section': 'hidden'}),
(('CWUser', 'login', '*'),
{'formtype': 'main', 'section': 'attributes'}),
(('CWUser', 'login', '*'),
{'formtype': 'muledit', 'section': 'attributes'}),
(('CWUser', 'login', 'String'),
{'formtype': 'main', 'section': 'inlined'}),
(('CWUser', 'login', 'String'),
{'formtype': 'inlined', 'section': 'attributes'}),
)
for key, kwargs in new_def:
uicfg.autoform_section.tag_subject_of(key, **kwargs)
......@@ -62,13 +65,11 @@ class DefinitionOrderTC(CubicWebTC):
for rtag in (uicfg.autoform_section, uicfg.autoform_field_kwargs):
rtag._tagdefs = rtag._old_tagdefs
@tag('uicfg')
def test_definition_order_hidden(self):
result = uicfg.autoform_section.get('CWUser', 'login', 'String', 'subject')
expected = set(['main_inlined', 'muledit_attributes', 'inlined_attributes'])
self.assertSetEqual(result, expected)
@tag('uihelper', 'order', 'func')
def test_uihelper_set_fields_order(self):
afk_get = uicfg.autoform_field_kwargs.get
self.assertEqual(afk_get('CWUser', 'firstname', 'String', 'subject'), {})
......@@ -78,7 +79,6 @@ class DefinitionOrderTC(CubicWebTC):
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertEqual(afk_get('CWUser', 'firstname', 'String', 'subject'), {'order': 1})
@tag('uicfg', 'order', 'func')
def test_uicfg_primaryview_set_fields_order(self):
pvdc = uicfg.primaryview_display_ctrl
pvdc.set_fields_order('CWUser', ('login', 'firstname', 'surname'))
......@@ -86,7 +86,6 @@ class DefinitionOrderTC(CubicWebTC):
self.assertEqual(pvdc.get('CWUser', 'firstname', 'String', 'subject'), {'order': 1})
self.assertEqual(pvdc.get('CWUser', 'surname', 'String', 'subject'), {'order': 2})
@tag('uihelper', 'kwargs', 'func')
def test_uihelper_set_field_kwargs(self):
afk_get = uicfg.autoform_field_kwargs.get
self.assertEqual(afk_get('CWUser', 'firstname', 'String', 'subject'), {})
......@@ -97,7 +96,6 @@ class DefinitionOrderTC(CubicWebTC):
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertEqual(afk_get('CWUser', 'firstname', 'String', 'subject'), {'widget': wdg})
@tag('uihelper', 'hidden', 'func')
def test_uihelper_hide_fields(self):
# original conf : in_group is edited in 'attributes' section everywhere
section_conf = uicfg.autoform_section.get('CWUser', 'in_group', '*', 'subject')
......@@ -117,13 +115,14 @@ class DefinitionOrderTC(CubicWebTC):
section_conf = uicfg.autoform_section.get('CWUser', 'in_group', '*', 'subject')
self.assertCountEqual(section_conf, ['main_hidden', 'muledit_hidden'])
@tag('uihelper', 'hidden', 'formconfig')
def test_uihelper_formconfig(self):
afk_get = uicfg.autoform_field_kwargs.get
class CWUserFormConfig(uihelper.FormConfig):
etype = 'CWUser'
hidden = ('in_group',)
fields_order = ('login', 'firstname')
section_conf = uicfg.autoform_section.get('CWUser', 'in_group', '*', 'subject')
self.assertCountEqual(section_conf, ['main_hidden', 'muledit_attributes'])
self.assertEqual(afk_get('CWUser', 'firstname', 'String', 'subject'), {'order': 1})
......@@ -148,6 +147,55 @@ class UicfgRegistryTC(CubicWebTC):
self.assertTrue(obj is custom_afs)
def _schema():
class Personne(EntityType):
pass
class Societe(EntityType):
pass
class Tag(EntityType):
pass
class travaille(RelationDefinition):
subject = 'Personne'
object = 'Societe'
class tags(RelationDefinition):
subject = 'Tag'
object = ('Personne', 'Societe', 'Tag')
return build_schema_from_namespace(locals().items())
class AutoformSectionTC(BaseTestCase):
def test_derivation(self):
schema = _schema()
afs = uicfg.AutoformSectionRelationTags()
afs.tag_subject_of(('Personne', 'travaille', '*'), 'main', 'relations')
afs.tag_object_of(('*', 'travaille', 'Societe'), 'main', 'relations')
afs.tag_subject_of(('Tag', 'tags', '*'), 'main', 'relations')
afs2 = afs.derive(__name__, afs.__select__)
afs2.tag_subject_of(('Personne', 'travaille', '*'), 'main', 'attributes')
afs2.tag_object_of(('*', 'travaille', 'Societe'), 'main', 'attributes')
afs2.tag_subject_of(('Tag', 'tags', 'Societe'), 'main', 'attributes')
afs.init(schema)
afs2.init(schema)
self.assertEqual(afs2.etype_get('Tag', 'tags', 'subject', 'Personne'),
set(('main_relations', 'muledit_hidden', 'inlined_relations')))
self.assertEqual(afs2.etype_get('Tag', 'tags', 'subject', 'Societe'),
set(('main_attributes', 'muledit_hidden', 'inlined_attributes')))
self.assertEqual(afs2.etype_get('Personne', 'travaille', 'subject', 'Societe'),
set(('main_attributes', 'muledit_hidden', 'inlined_attributes')))
self.assertEqual(afs2.etype_get('Societe', 'travaille', 'object', 'Personne'),
set(('main_attributes', 'muledit_hidden', 'inlined_attributes')))
if __name__ == '__main__':
from logilab.common.testlib import unittest_main
unittest_main()
import unittest
unittest.main()
# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# copyright 2003 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
......@@ -118,16 +118,10 @@ checking for dark-corner case where it can't be verified properly.
.. Controlling the generic relation fields
"""
from cubicweb import _
from warnings import warn
from six.moves import range
from logilab.mtconverter import xml_escape
from logilab.common.decorators import iclassmethod, cached
from logilab.common.deprecation import deprecated
from logilab.common.registry import NoSelectableObject
from cubicweb import neg_role, uilib
......@@ -185,7 +179,7 @@ class InlinedFormField(ff.Field):
return False
def process_posted(self, form):
pass # handled by the subform
pass # handled by the subform
class InlineEntityEditionFormView(f.FormViewMixIn, EntityView):
......@@ -237,7 +231,7 @@ class InlineEntityEditionFormView(f.FormViewMixIn, EntityView):
**self.cw_extra_kwargs)
if self.pform is None:
form.restore_previous_post(form.session_key())
#assert form.parent_form
# assert form.parent_form
self.add_hiddens(form, entity)
return form
......@@ -261,7 +255,7 @@ class InlineEntityEditionFormView(f.FormViewMixIn, EntityView):
entity = self._entity()
rdef = entity.e_schema.rdef(self.rtype, neg_role(self.role), self.petype)
card = rdef.role_cardinality(self.role)
if card == '1': # don't display remove link
if card == '1': # don't display remove link
return None
# if cardinality is 1..n (+), dont display link to remove an inlined form for the first form
# allowing to edit the relation. To detect so:
......@@ -294,7 +288,7 @@ class InlineEntityEditionFormView(f.FormViewMixIn, EntityView):
except KeyError:
self._cw.data[countkey] = 1
self.form.render(w=self.w, divid=divid, title=title, removejs=removejs,
i18nctx=i18nctx, counter=self._cw.data[countkey] ,
i18nctx=i18nctx, counter=self._cw.data[countkey],
**kwargs)
def form_title(self, entity, i18nctx):
......@@ -374,21 +368,21 @@ class InlineAddNewLinkView(InlineEntityCreationFormView):
& specified_etype_implements('Any'))
_select_attrs = InlineEntityCreationFormView._select_attrs + ('card',)
card = None # make pylint happy
form = None # no actual form wrapped
card = None # make pylint happy
form = None # no actual form wrapped
def call(self, i18nctx, **kwargs):
self._cw.set_varmaker()
divid = "addNew%s%s%s:%s" % (self.etype, self.rtype, self.role, self.peid)
self.w(u'<div class="inlinedform" id="%s" cubicweb:limit="true">'
% divid)
% divid)
js = "addInlineCreationForm('%s', '%s', '%s', '%s', '%s', '%s')" % (
self.peid, self.petype, self.etype, self.rtype, self.role, i18nctx)
if self.pform.should_hide_add_new_relation_link(self.rtype, self.card):
js = "toggleVisibility('%s'); %s" % (divid, js)
__ = self._cw.pgettext
self.w(u'<a class="addEntity" id="add%s:%slink" href="javascript: %s" >+ %s.</a>'
% (self.rtype, self.peid, js, __(i18nctx, 'add a %s' % self.etype)))
% (self.rtype, self.peid, js, __(i18nctx, 'add a %s' % self.etype)))
self.w(u'</div>')
......@@ -400,6 +394,7 @@ def relation_id(eid, rtype, role, reid):
return u'%s:%s:%s' % (eid, rtype, reid)
return u'%s:%s:%s' % (reid, rtype, eid)
def toggleable_relation_link(eid, nodeid, label='x'):
"""return javascript snippet to delete/undelete a relation between two