web/views/reledit.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """edit entity attributes/relations from any view, without going to the entity
       
    19 form
       
    20 """
       
    21 
       
    22 __docformat__ = "restructuredtext en"
       
    23 from cubicweb import _
       
    24 
       
    25 import copy
       
    26 from warnings import warn
       
    27 
       
    28 from logilab.mtconverter import xml_escape
       
    29 from logilab.common.deprecation import deprecated, class_renamed
       
    30 from logilab.common.decorators import cached
       
    31 
       
    32 from cubicweb import neg_role
       
    33 from cubicweb.schema import display_name
       
    34 from cubicweb.utils import json, json_dumps
       
    35 from cubicweb.predicates import non_final_entity, match_kwargs
       
    36 from cubicweb.view import EntityView
       
    37 from cubicweb.web import stdmsgs
       
    38 from cubicweb.web.views import uicfg
       
    39 from cubicweb.web.form import FieldNotFound
       
    40 from cubicweb.web.formwidgets import Button, SubmitButton
       
    41 from cubicweb.web.views.ajaxcontroller import ajaxfunc
       
    42 
       
    43 class _DummyForm(object):
       
    44     __slots__ = ('event_args',)
       
    45     def form_render(self, **_args):
       
    46         return u''
       
    47     def render(self, *_args, **_kwargs):
       
    48         return u''
       
    49     def append_field(self, *args):
       
    50         pass
       
    51     def add_hidden(self, *args):
       
    52         pass
       
    53 
       
    54 class AutoClickAndEditFormView(EntityView):
       
    55     __regid__ = 'reledit'
       
    56     __select__ = non_final_entity() & match_kwargs('rtype')
       
    57 
       
    58     # ui side continuations
       
    59     _onclick = (u"cw.reledit.loadInlineEditionForm('%(formid)s', %(eid)s, '%(rtype)s', '%(role)s', "
       
    60                 "'%(divid)s', %(reload)s, '%(vid)s', '%(action)s');")
       
    61     _cancelclick = "cw.reledit.cleanupAfterCancel('%s')"
       
    62 
       
    63     # ui side actions/buttons
       
    64     _addzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
       
    65     _addmsg = _('click to add a value')
       
    66     _addlogo = 'plus.png'
       
    67     _deletezone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
       
    68     _deletemsg = _('click to delete this value')
       
    69     _deletelogo = 'cancel.png'
       
    70     _editzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
       
    71     _editzonemsg = _('click to edit this field')
       
    72     _editlogo = 'pen_icon.png'
       
    73 
       
    74     # renderer
       
    75     _form_renderer_id = 'base'
       
    76 
       
    77     def entity_call(self, entity, rtype=None, role='subject',
       
    78                     reload=False, # controls reloading the whole page after change
       
    79                                   # boolean, eid (to redirect), or
       
    80                                   # function taking the subject entity & returning a boolean or an eid
       
    81                     rvid=None,    # vid to be applied to other side of rtype (non final relations only)
       
    82                     default_value=None,
       
    83                     formid='base',
       
    84                     action=None
       
    85                     ):
       
    86         """display field to edit entity's `rtype` relation on click"""
       
    87         assert rtype
       
    88         self._cw.add_css('cubicweb.form.css')
       
    89         self._cw.add_js(('cubicweb.reledit.js', 'cubicweb.edition.js', 'cubicweb.ajax.js'))
       
    90         self.entity = entity
       
    91         rschema = self._cw.vreg.schema[rtype]
       
    92         rctrl = self._cw.vreg['uicfg'].select('reledit', self._cw, entity=entity)
       
    93         self._rules = rctrl.etype_get(self.entity.e_schema.type, rschema.type, role, '*')
       
    94         reload = self._compute_reload(rschema, role, reload)
       
    95         divid = self._build_divid(rtype, role, self.entity.eid)
       
    96         if rschema.final:
       
    97             self._handle_attribute(rschema, role, divid, reload, action)
       
    98         else:
       
    99             if self._is_composite():
       
   100                 self._handle_composite(rschema, role, divid, reload, formid, action)
       
   101             else:
       
   102                 self._handle_relation(rschema, role, divid, reload, formid, action)
       
   103 
       
   104     def _handle_attribute(self, rschema, role, divid, reload, action):
       
   105         rvid = self._rules.get('rvid', None)
       
   106         if rvid is not None:
       
   107             value = self._cw.view(rvid, entity=self.entity,
       
   108                                   rtype=rschema.type, role=role)
       
   109         else:
       
   110             value = self.entity.printable_value(rschema.type)
       
   111         if not self._should_edit_attribute(rschema):
       
   112             self.w(value)
       
   113             return
       
   114         form, renderer = self._build_form(self.entity, rschema, role, divid,
       
   115                                           'base', reload, action)
       
   116         value = value or self._compute_default_value(rschema, role)
       
   117         self.view_form(divid, value, form, renderer)
       
   118 
       
   119     def _compute_formid_value(self, rschema, role, rvid, formid):
       
   120         related_rset = self.entity.related(rschema.type, role)
       
   121         if related_rset:
       
   122             value = self._cw.view(rvid, related_rset)
       
   123         else:
       
   124             value = self._compute_default_value(rschema, role)
       
   125         if not self._should_edit_relation(rschema, role):
       
   126             return None, value
       
   127         return formid, value
       
   128 
       
   129     def _handle_relation(self, rschema, role, divid, reload, formid, action):
       
   130         rvid = self._rules.get('rvid', 'autolimited')
       
   131         formid, value = self._compute_formid_value(rschema, role, rvid, formid)
       
   132         if formid is None:
       
   133             return self.w(value)
       
   134         form, renderer = self._build_form(self.entity,  rschema, role, divid, formid,
       
   135                                           reload, action, dict(vid=rvid))
       
   136         self.view_form(divid, value, form, renderer)
       
   137 
       
   138     def _handle_composite(self, rschema, role, divid, reload, formid, action):
       
   139         # this is for attribute-like composites (1 target type, 1 related entity at most, for now)
       
   140         entity = self.entity
       
   141         related_rset = entity.related(rschema.type, role)
       
   142         add_related = self._may_add_related(related_rset, rschema, role)
       
   143         edit_related = self._may_edit_related_entity(related_rset, rschema, role)
       
   144         delete_related = edit_related and self._may_delete_related(related_rset, rschema, role)
       
   145         rvid = self._rules.get('rvid', 'autolimited')
       
   146         formid, value = self._compute_formid_value(rschema, role, rvid, formid)
       
   147         if formid is None or not (edit_related or add_related):
       
   148             # till we learn to handle cases where not (edit_related or add_related)
       
   149             self.w(value)
       
   150             return
       
   151         form, renderer = self._build_form(entity, rschema, role, divid, formid,
       
   152                                           reload, action, dict(vid=rvid))
       
   153         self.view_form(divid, value, form, renderer,
       
   154                        edit_related, add_related, delete_related)
       
   155 
       
   156     @cached
       
   157     def _compute_ttypes(self, rschema, role):
       
   158         dual_role = neg_role(role)
       
   159         return getattr(rschema, '%ss' % dual_role)()
       
   160 
       
   161     def _compute_reload(self, rschema, role, reload):
       
   162         ctrl_reload = self._rules.get('reload', reload)
       
   163         if callable(ctrl_reload):
       
   164             ctrl_reload = ctrl_reload(self.entity)
       
   165         if isinstance(ctrl_reload, int) and ctrl_reload > 1: # not True/False
       
   166             ctrl_reload = self._cw.build_url(ctrl_reload)
       
   167         return ctrl_reload
       
   168 
       
   169     def _compute_default_value(self, rschema, role):
       
   170         default = self._rules.get('novalue_label')
       
   171         if default is None:
       
   172             if self._rules.get('novalue_include_rtype'):
       
   173                 default = self._cw._('<%s not specified>') % display_name(
       
   174                     self._cw, rschema.type, role)
       
   175             else:
       
   176                 default = self._cw._('<not specified>')
       
   177         else:
       
   178             default = self._cw._(default)
       
   179         return xml_escape(default)
       
   180 
       
   181     def _is_composite(self):
       
   182         return self._rules.get('edit_target') == 'related'
       
   183 
       
   184     def _may_add_related(self, related_rset, rschema, role):
       
   185         """ ok for attribute-like composite entities """
       
   186         ttypes = self._compute_ttypes(rschema, role)
       
   187         if len(ttypes) > 1: # many etypes: learn how to do it
       
   188             return False
       
   189         rdef = rschema.role_rdef(self.entity.e_schema, ttypes[0], role)
       
   190         card = rdef.role_cardinality(role)
       
   191         if related_rset or card not in '?1':
       
   192             return False
       
   193         if role == 'subject':
       
   194             kwargs = {'fromeid': self.entity.eid}
       
   195         else:
       
   196             kwargs = {'toeid': self.entity.eid}
       
   197         return rdef.has_perm(self._cw, 'add', **kwargs)
       
   198 
       
   199     def _may_edit_related_entity(self, related_rset, rschema, role):
       
   200         """ controls the edition of the related entity """
       
   201         ttypes = self._compute_ttypes(rschema, role)
       
   202         if len(ttypes) > 1 or len(related_rset.rows) != 1:
       
   203             return False
       
   204         if self.entity.e_schema.rdef(rschema, role).role_cardinality(role) not in '?1':
       
   205             return False
       
   206         return related_rset.get_entity(0, 0).cw_has_perm('update')
       
   207 
       
   208     def _may_delete_related(self, related_rset, rschema, role):
       
   209         # we assume may_edit_related, only 1 related entity
       
   210         if not related_rset:
       
   211             return False
       
   212         rentity = related_rset.get_entity(0, 0)
       
   213         entity = self.entity
       
   214         if role == 'subject':
       
   215             kwargs = {'fromeid': entity.eid, 'toeid': rentity.eid}
       
   216             cardinality = rschema.rdefs[(entity.cw_etype, rentity.cw_etype)].cardinality[0]
       
   217         else:
       
   218             kwargs = {'fromeid': rentity.eid, 'toeid': entity.eid}
       
   219             cardinality = rschema.rdefs[(rentity.cw_etype, entity.cw_etype)].cardinality[1]
       
   220         if cardinality in '1+':
       
   221             return False
       
   222         # NOTE: should be sufficient given a well built schema/security
       
   223         return rschema.has_perm(self._cw, 'delete', **kwargs)
       
   224 
       
   225     def _build_zone(self, zonedef, msg, logo):
       
   226         return zonedef % {'msg': xml_escape(self._cw._(msg)),
       
   227                           'logo': xml_escape(self._cw.data_url(logo))}
       
   228 
       
   229     def _build_edit_zone(self):
       
   230         return self._build_zone(self._editzone, self._editzonemsg, self._editlogo)
       
   231 
       
   232     def _build_delete_zone(self):
       
   233         return self._build_zone(self._deletezone, self._deletemsg, self._deletelogo)
       
   234 
       
   235     def _build_add_zone(self):
       
   236         return self._build_zone(self._addzone, self._addmsg, self._addlogo)
       
   237 
       
   238     def _build_divid(self, rtype, role, entity_eid):
       
   239         """ builds an id for the root div of a reledit widget """
       
   240         return '%s-%s-%s' % (rtype, role, entity_eid)
       
   241 
       
   242     def _build_args(self, entity, rtype, role, formid, reload, action,
       
   243                     extradata=None):
       
   244         divid = self._build_divid(rtype, role, entity.eid)
       
   245         event_args = {'divid' : divid, 'eid' : entity.eid, 'rtype' : rtype, 'formid': formid,
       
   246                       'reload' : json_dumps(reload), 'action': action,
       
   247                       'role' : role, 'vid' : u''}
       
   248         if extradata:
       
   249             event_args.update(extradata)
       
   250         return event_args
       
   251 
       
   252     def _prepare_form(self, entity, rschema, role, action):
       
   253         assert action in ('edit_rtype', 'edit_related', 'add', 'delete'), action
       
   254         if action == 'edit_rtype':
       
   255             return False, entity
       
   256         label = True
       
   257         if action in ('edit_related', 'delete'):
       
   258             edit_entity = entity.related(rschema, role).get_entity(0, 0)
       
   259         elif action == 'add':
       
   260             add_etype = self._compute_ttypes(rschema, role)[0]
       
   261             _new_entity = self._cw.vreg['etypes'].etype_class(add_etype)(self._cw)
       
   262             _new_entity.eid = next(self._cw.varmaker)
       
   263             edit_entity = _new_entity
       
   264             # XXX see forms.py ~ 276 and entities.linked_to method
       
   265             #     is there another way?
       
   266             self._cw.form['__linkto'] = '%s:%s:%s' % (rschema, entity.eid, neg_role(role))
       
   267         assert edit_entity
       
   268         return label, edit_entity
       
   269 
       
   270     def _build_renderer(self, related_entity, display_label):
       
   271         return self._cw.vreg['formrenderers'].select(
       
   272             self._form_renderer_id, self._cw, entity=related_entity,
       
   273             display_label=display_label,
       
   274             table_class='attributeForm' if display_label else '',
       
   275             display_help=False, button_bar_class='buttonbar',
       
   276             display_progress_div=False)
       
   277 
       
   278     def _build_form(self, entity, rschema, role, divid, formid, reload, action,
       
   279                     extradata=None, **formargs):
       
   280         rtype = rschema.type
       
   281         event_args = self._build_args(entity, rtype, role, formid, reload, action, extradata)
       
   282         if not action:
       
   283             form = _DummyForm()
       
   284             form.event_args = event_args
       
   285             return form, None
       
   286         label, edit_entity = self._prepare_form(entity, rschema, role, action)
       
   287         cancelclick = self._cancelclick % divid
       
   288         form = self._cw.vreg['forms'].select(
       
   289             formid, self._cw, rset=edit_entity.as_rset(), entity=edit_entity,
       
   290             domid='%s-form' % divid, formtype='inlined',
       
   291             action=self._cw.build_url('validateform', __onsuccess='window.parent.cw.reledit.onSuccess'),
       
   292             cwtarget='eformframe', cssclass='releditForm',
       
   293             **formargs)
       
   294         # pass reledit arguments
       
   295         for pname, pvalue in event_args.items():
       
   296             form.add_hidden('__reledit|' + pname, pvalue)
       
   297         # handle buttons
       
   298         if form.form_buttons: # edition, delete
       
   299             form_buttons = []
       
   300             for button in form.form_buttons:
       
   301                 if not button.label.endswith('apply'):
       
   302                     if button.label.endswith('cancel'):
       
   303                         button = copy.deepcopy(button)
       
   304                         button.cwaction = None
       
   305                         button.onclick = cancelclick
       
   306                     form_buttons.append(button)
       
   307             form.form_buttons = form_buttons
       
   308         else: # base
       
   309             form.form_buttons = [SubmitButton(),
       
   310                                  Button(stdmsgs.BUTTON_CANCEL, onclick=cancelclick)]
       
   311         form.event_args = event_args
       
   312         if formid == 'base':
       
   313             field = form.field_by_name(rtype, role, entity.e_schema)
       
   314             form.append_field(field)
       
   315         return form, self._build_renderer(edit_entity, label)
       
   316 
       
   317     def _should_edit_attribute(self, rschema):
       
   318         entity = self.entity
       
   319         rdef = entity.e_schema.rdef(rschema)
       
   320         # check permissions
       
   321         if not entity.cw_has_perm('update'):
       
   322             return False
       
   323         rdef = entity.e_schema.rdef(rschema)
       
   324         return rdef.has_perm(self._cw, 'update', eid=entity.eid)
       
   325 
       
   326     def _should_edit_relation(self, rschema, role):
       
   327         eeid = self.entity.eid
       
   328         perm_args = {'fromeid': eeid} if role == 'subject' else {'toeid': eeid}
       
   329         return rschema.has_perm(self._cw, 'add', **perm_args)
       
   330 
       
   331     def _open_form_wrapper(self, divid, value, form, renderer,
       
   332                            _edit_related, _add_related, _delete_related):
       
   333         w = self.w
       
   334         w(u'<div id="%(id)s-reledit" onmouseout="%(out)s" onmouseover="%(over)s" class="%(css)s">' %
       
   335           {'id': divid, 'css': 'releditField',
       
   336            'out': "jQuery('#%s').addClass('invisible')" % divid,
       
   337            'over': "jQuery('#%s').removeClass('invisible')" % divid})
       
   338         w(u'<div id="%s-value" class="editableFieldValue">' % divid)
       
   339         w(value)
       
   340         w(u'</div>')
       
   341         form.render(w=w, renderer=renderer)
       
   342         w(u'<div id="%s" class="editableField invisible">' % divid)
       
   343 
       
   344     def _edit_action(self, divid, args, edit_related, add_related, _delete_related):
       
   345         # XXX disambiguate wrt edit_related
       
   346         if not add_related: # currently, excludes edition
       
   347             w = self.w
       
   348             args['formid'] = 'edition' if edit_related else 'base'
       
   349             args['action'] = 'edit_related' if edit_related else 'edit_rtype'
       
   350             w(u'<div id="%s-update" class="editableField" onclick="%s" title="%s">' %
       
   351               (divid, xml_escape(self._onclick % args), self._cw._(self._editzonemsg)))
       
   352             w(self._build_edit_zone())
       
   353             w(u'</div>')
       
   354 
       
   355     def _add_action(self, divid, args, _edit_related, add_related, _delete_related):
       
   356         if add_related:
       
   357             w = self.w
       
   358             args['formid'] = 'edition'
       
   359             args['action'] = 'add'
       
   360             w(u'<div id="%s-add" class="editableField" onclick="%s" title="%s">' %
       
   361               (divid, xml_escape(self._onclick % args), self._cw._(self._addmsg)))
       
   362             w(self._build_add_zone())
       
   363             w(u'</div>')
       
   364 
       
   365     def _del_action(self, divid, args, _edit_related, _add_related, delete_related):
       
   366         if delete_related:
       
   367             w = self.w
       
   368             args['formid'] = 'deleteconf'
       
   369             args['action'] = 'delete'
       
   370             w(u'<div id="%s-delete" class="editableField" onclick="%s" title="%s">' %
       
   371               (divid, xml_escape(self._onclick % args), self._cw._(self._deletemsg)))
       
   372             w(self._build_delete_zone())
       
   373             w(u'</div>')
       
   374 
       
   375     def _close_form_wrapper(self):
       
   376         self.w(u'</div>')
       
   377         self.w(u'</div>')
       
   378 
       
   379     def view_form(self, divid, value, form=None, renderer=None,
       
   380                   edit_related=False, add_related=False, delete_related=False):
       
   381         self._open_form_wrapper(divid, value, form, renderer,
       
   382                                 edit_related, add_related, delete_related)
       
   383         args = form.event_args.copy()
       
   384         self._edit_action(divid, args, edit_related, add_related, delete_related)
       
   385         self._add_action(divid, args, edit_related, add_related, delete_related)
       
   386         self._del_action(divid, args, edit_related, add_related, delete_related)
       
   387         self._close_form_wrapper()
       
   388 
       
   389 
       
   390 ClickAndEditFormView = class_renamed('ClickAndEditFormView', AutoClickAndEditFormView)
       
   391 
       
   392 
       
   393 @ajaxfunc(output_type='xhtml')
       
   394 def reledit_form(self):
       
   395     req = self._cw
       
   396     args = dict((x, req.form[x])
       
   397                 for x in ('formid', 'rtype', 'role', 'reload', 'action'))
       
   398     rset = req.eid_rset(int(self._cw.form['eid']))
       
   399     try:
       
   400         args['reload'] = json.loads(args['reload'])
       
   401     except ValueError: # not true/false, an absolute url
       
   402         assert args['reload'].startswith('http')
       
   403     view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype'])
       
   404     return self._call_view(view, **args)