web/views/reledit.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 22 Jun 2011 18:53:38 +0200
changeset 7553 935423529f45
parent 7156 b1521f1546e0
child 7834 258fc0b4a1e0
permissions -rw-r--r--
[datafeed cw parser] refactor: split logic that was in the parser into: * an "item builder" component, turning an etree xml node into a specific python dictionnary representing an entity * "action" components, selected given an entity, a relation and its role in the relation, and responsible to link the entity to given related items (eg dictionnary) This changes make it easy to add new action or to override existing one's behaviour according to the entity, the relation, etc... Also refactor the xml view so one can also override what's included in the tag used to mark the entity as used by a relation, hopefully allowing to reduce the number of http requests needed for a full datafeed import.

# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""edit entity attributes/relations from any view, without going to the entity
form
"""

__docformat__ = "restructuredtext en"
_ = unicode

import copy
from warnings import warn

from logilab.mtconverter import xml_escape
from logilab.common.deprecation import deprecated, class_renamed
from logilab.common.decorators import cached

from cubicweb import neg_role
from cubicweb.schema import display_name
from cubicweb.utils import json_dumps
from cubicweb.selectors import non_final_entity, match_kwargs
from cubicweb.view import EntityView
from cubicweb.web import uicfg, stdmsgs
from cubicweb.web.form import FieldNotFound
from cubicweb.web.formwidgets import Button, SubmitButton

class _DummyForm(object):
    __slots__ = ('event_args',)
    def form_render(self, **_args):
        return u''
    def render(self, *_args, **_kwargs):
        return u''
    def append_field(self, *args):
        pass
    def add_hidden(self, *args):
        pass

rctrl = uicfg.reledit_ctrl

class AutoClickAndEditFormView(EntityView):
    __regid__ = 'reledit'
    __select__ = non_final_entity() & match_kwargs('rtype')

    # ui side continuations
    _onclick = (u"cw.reledit.loadInlineEditionForm('%(formid)s', %(eid)s, '%(rtype)s', '%(role)s', "
                "'%(divid)s', %(reload)s, '%(vid)s', '%(action)s');")
    _cancelclick = "cw.reledit.cleanupAfterCancel('%s')"

    # ui side actions/buttons
    _addzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
    _addmsg = _('click to add a value')
    _addlogo = 'plus.png'
    _deletezone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
    _deletemsg = _('click to delete this value')
    _deletelogo = 'cancel.png'
    _editzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>'
    _editzonemsg = _('click to edit this field')
    _editlogo = 'pen_icon.png'

    # renderer
    _form_renderer_id = 'base'

    def cell_call(self, row, col, rtype=None, role='subject',
                  reload=False, # controls reloading the whole page after change
                                # boolean, eid (to redirect), or
                                # function taking the subject entity & returning a boolean or an eid
                  rvid=None,    # vid to be applied to other side of rtype (non final relations only)
                  default_value=None,
                  formid='base',
                  action=None
                  ):
        """display field to edit entity's `rtype` relation on click"""
        assert rtype
        self._cw.add_css('cubicweb.form.css')
        self._cw.add_js(('cubicweb.reledit.js', 'cubicweb.edition.js', 'cubicweb.ajax.js'))
        self.entity = self.cw_rset.get_entity(row, col)
        rschema = self._cw.vreg.schema[rtype]
        self._rules = rctrl.etype_get(self.entity.e_schema.type, rschema.type, role, '*')
        if rvid is not None or default_value is not None:
            warn('[3.9] specifying rvid/default_value on select is deprecated, '
                 'reledit_ctrl rtag to control this' % self, DeprecationWarning)
        reload = self._compute_reload(rschema, role, reload)
        divid = self._build_divid(rtype, role, self.entity.eid)
        if rschema.final:
            self._handle_attribute(rschema, role, divid, reload, action)
        else:
            if self._is_composite():
                self._handle_composite(rschema, role, divid, reload, formid, action)
            else:
                self._handle_relation(rschema, role, divid, reload, formid, action)

    def _handle_attribute(self, rschema, role, divid, reload, action):
        value = self.entity.printable_value(rschema.type)
        if not self._should_edit_attribute(rschema):
            self.w(value)
            return
        form, renderer = self._build_form(self.entity, rschema, role, divid, 'base', reload, action)
        value = value or self._compute_default_value(rschema, role)
        self.view_form(divid, value, form, renderer)

    def _compute_formid_value(self, rschema, role, rvid, formid):
        related_rset = self.entity.related(rschema.type, role)
        if related_rset:
            value = self._cw.view(rvid, related_rset)
        else:
            value = self._compute_default_value(rschema, role)
        if not self._should_edit_relation(rschema, role):
            return None, value
        return formid, value

    def _handle_relation(self, rschema, role, divid, reload, formid, action):
        rvid = self._rules.get('rvid', 'autolimited')
        formid, value = self._compute_formid_value(rschema, role, rvid, formid)
        if formid is None:
            return self.w(value)
        form, renderer = self._build_form(self.entity,  rschema, role, divid, formid,
                                          reload, action, dict(vid=rvid))
        self.view_form(divid, value, form, renderer)

    def _handle_composite(self, rschema, role, divid, reload, formid, action):
        # this is for attribute-like composites (1 target type, 1 related entity at most, for now)
        entity = self.entity
        related_rset = entity.related(rschema.type, role)
        add_related = self._may_add_related(related_rset, rschema, role)
        edit_related = self._may_edit_related_entity(related_rset, rschema, role)
        delete_related = edit_related and self._may_delete_related(related_rset, rschema, role)
        rvid = self._rules.get('rvid', 'autolimited')
        formid, value = self._compute_formid_value(rschema, role, rvid, formid)
        if formid is None or not (edit_related or add_related):
            # till we learn to handle cases where not (edit_related or add_related)
            self.w(value)
            return
        form, renderer = self._build_form(entity, rschema, role, divid, formid,
                                          reload, action, dict(vid=rvid))
        self.view_form(divid, value, form, renderer,
                       edit_related, add_related, delete_related)

    @cached
    def _compute_ttypes(self, rschema, role):
        dual_role = neg_role(role)
        return getattr(rschema, '%ss' % dual_role)()

    def _compute_reload(self, rschema, role, reload):
        ctrl_reload = self._rules.get('reload', reload)
        if callable(ctrl_reload):
            ctrl_reload = ctrl_reload(self.entity)
        if isinstance(ctrl_reload, int) and ctrl_reload > 1: # not True/False
            ctrl_reload = self._cw.build_url(ctrl_reload)
        return ctrl_reload

    def _compute_default_value(self, rschema, role):
        default = self._rules.get('novalue_label')
        if default is None:
            if self._rules.get('novalue_include_rtype'):
                default = self._cw._('<%s not specified>') % display_name(
                    self._cw, rschema.type, role)
            else:
                default = self._cw._('<not specified>')
        return xml_escape(default)

    def _is_composite(self):
        return self._rules.get('edit_target') == 'related'

    def _may_add_related(self, related_rset, rschema, role):
        """ ok for attribute-like composite entities """
        ttypes = self._compute_ttypes(rschema, role)
        if len(ttypes) > 1: # many etypes: learn how to do it
            return False
        rdef = rschema.role_rdef(self.entity.e_schema, ttypes[0], role)
        card = rdef.role_cardinality(role)
        if related_rset or card not in '?1':
            return False
        if role == 'subject':
            kwargs = {'fromeid': self.entity.eid}
        else:
            kwargs = {'toeid': self.entity.eid}
        return rdef.has_perm(self._cw, 'add', **kwargs)

    def _may_edit_related_entity(self, related_rset, rschema, role):
        """ controls the edition of the related entity """
        ttypes = self._compute_ttypes(rschema, role)
        if len(ttypes) > 1 or len(related_rset.rows) != 1:
            return False
        if self.entity.e_schema.rdef(rschema, role).role_cardinality(role) not in '?1':
            return False
        return related_rset.get_entity(0, 0).cw_has_perm('update')

    def _may_delete_related(self, related_rset, rschema, role):
        # we assume may_edit_related, only 1 related entity
        if not related_rset:
            return False
        rentity = related_rset.get_entity(0, 0)
        entity = self.entity
        if role == 'subject':
            kwargs = {'fromeid': entity.eid, 'toeid': rentity.eid}
        else:
            kwargs = {'fromeid': rentity.eid, 'toeid': entity.eid}
        # NOTE: should be sufficient given a well built schema/security
        return rschema.has_perm(self._cw, 'delete', **kwargs)

    def _build_zone(self, zonedef, msg, logo):
        return zonedef % {'msg': xml_escape(self._cw._(msg)),
                          'logo': xml_escape(self._cw.data_url(logo))}

    def _build_edit_zone(self):
        return self._build_zone(self._editzone, self._editzonemsg, self._editlogo)

    def _build_delete_zone(self):
        return self._build_zone(self._deletezone, self._deletemsg, self._deletelogo)

    def _build_add_zone(self):
        return self._build_zone(self._addzone, self._addmsg, self._addlogo)

    def _build_divid(self, rtype, role, entity_eid):
        """ builds an id for the root div of a reledit widget """
        return '%s-%s-%s' % (rtype, role, entity_eid)

    def _build_args(self, entity, rtype, role, formid, reload, action,
                    extradata=None):
        divid = self._build_divid(rtype, role, entity.eid)
        event_args = {'divid' : divid, 'eid' : entity.eid, 'rtype' : rtype, 'formid': formid,
                      'reload' : json_dumps(reload), 'action': action,
                      'role' : role, 'vid' : u''}
        if extradata:
            event_args.update(extradata)
        return event_args

    def _prepare_form(self, entity, rschema, role, action):
        assert action in ('edit_rtype', 'edit_related', 'add', 'delete'), action
        if action == 'edit_rtype':
            return False, entity
        label = True
        if action in ('edit_related', 'delete'):
            edit_entity = entity.related(rschema, role).get_entity(0, 0)
        elif action == 'add':
            add_etype = self._compute_ttypes(rschema, role)[0]
            _new_entity = self._cw.vreg['etypes'].etype_class(add_etype)(self._cw)
            _new_entity.eid = self._cw.varmaker.next()
            edit_entity = _new_entity
            # XXX see forms.py ~ 276 and entities.linked_to method
            #     is there another way ?
            self._cw.form['__linkto'] = '%s:%s:%s' % (rschema, entity.eid, neg_role(role))
        assert edit_entity
        return label, edit_entity

    def _build_renderer(self, related_entity, display_label):
        return self._cw.vreg['formrenderers'].select(
            self._form_renderer_id, self._cw, entity=related_entity,
            display_label=display_label,
            table_class='attributeForm' if display_label else '',
            display_help=False, button_bar_class='buttonbar',
            display_progress_div=False)

    def _build_form(self, entity, rschema, role, divid, formid, reload, action,
                    extradata=None, **formargs):
        rtype = rschema.type
        event_args = self._build_args(entity, rtype, role, formid, reload, action, extradata)
        if not action:
            form = _DummyForm()
            form.event_args = event_args
            return form, None
        label, edit_entity = self._prepare_form(entity, rschema, role, action)
        cancelclick = self._cancelclick % divid
        form = self._cw.vreg['forms'].select(
            formid, self._cw, rset=edit_entity.as_rset(), entity=edit_entity,
            domid='%s-form' % divid, formtype='inlined',
            action=self._cw.build_url('validateform', __onsuccess='window.parent.cw.reledit.onSuccess'),
            cwtarget='eformframe', cssclass='releditForm',
            **formargs)
        # pass reledit arguments
        for pname, pvalue in event_args.iteritems():
            form.add_hidden('__reledit|' + pname, pvalue)
        # handle buttons
        if form.form_buttons: # edition, delete
            form_buttons = []
            for button in form.form_buttons:
                if not button.label.endswith('apply'):
                    if button.label.endswith('cancel'):
                        button = copy.deepcopy(button)
                        button.cwaction = None
                        button.onclick = cancelclick
                    form_buttons.append(button)
            form.form_buttons = form_buttons
        else: # base
            form.form_buttons = [SubmitButton(),
                                 Button(stdmsgs.BUTTON_CANCEL, onclick=cancelclick)]
        form.event_args = event_args
        if formid == 'base':
            field = form.field_by_name(rtype, role, entity.e_schema)
            form.append_field(field)
        return form, self._build_renderer(edit_entity, label)

    def _should_edit_attribute(self, rschema):
        entity = self.entity
        rdef = entity.e_schema.rdef(rschema)
        # check permissions
        if not entity.cw_has_perm('update'):
            return False
        rdef = entity.e_schema.rdef(rschema)
        return rdef.has_perm(self._cw, 'update', eid=entity.eid)

    should_edit_attributes = deprecated('[3.9] should_edit_attributes is deprecated,'
                                        ' use _should_edit_attribute instead',
                                        _should_edit_attribute)

    def _should_edit_relation(self, rschema, role):
        eeid = self.entity.eid
        perm_args = {'fromeid': eeid} if role == 'subject' else {'toeid': eeid}
        return rschema.has_perm(self._cw, 'add', **perm_args)

    should_edit_relations = deprecated('[3.9] should_edit_relations is deprecated,'
                                       ' use _should_edit_relation instead',
                                       _should_edit_relation)

    def _open_form_wrapper(self, divid, value, form, renderer,
                           _edit_related, _add_related, _delete_related):
        w = self.w
        w(u'<div id="%(id)s-reledit" onmouseout="%(out)s" onmouseover="%(over)s" class="%(css)s">' %
          {'id': divid, 'css': 'releditField',
           'out': "jQuery('#%s').addClass('hidden')" % divid,
           'over': "jQuery('#%s').removeClass('hidden')" % divid})
        w(u'<div id="%s-value" class="editableFieldValue">' % divid)
        w(value)
        w(u'</div>')
        form.render(w=w, renderer=renderer)
        w(u'<div id="%s" class="editableField hidden">' % divid)

    def _edit_action(self, divid, args, edit_related, add_related, _delete_related):
        # XXX disambiguate wrt edit_related
        if not add_related: # currently, excludes edition
            w = self.w
            args['formid'] = 'edition' if edit_related else 'base'
            args['action'] = 'edit_related' if edit_related else 'edit_rtype'
            w(u'<div id="%s-update" class="editableField" onclick="%s" title="%s">' %
              (divid, xml_escape(self._onclick % args), self._cw._(self._editzonemsg)))
            w(self._build_edit_zone())
            w(u'</div>')

    def _add_action(self, divid, args, _edit_related, add_related, _delete_related):
        if add_related:
            w = self.w
            args['formid'] = 'edition'
            args['action'] = 'add'
            w(u'<div id="%s-add" class="editableField" onclick="%s" title="%s">' %
              (divid, xml_escape(self._onclick % args), self._cw._(self._addmsg)))
            w(self._build_add_zone())
            w(u'</div>')

    def _del_action(self, divid, args, _edit_related, _add_related, delete_related):
        if delete_related:
            w = self.w
            args['formid'] = 'deleteconf'
            args['action'] = 'delete'
            w(u'<div id="%s-delete" class="editableField" onclick="%s" title="%s">' %
              (divid, xml_escape(self._onclick % args), self._cw._(self._deletemsg)))
            w(self._build_delete_zone())
            w(u'</div>')

    def _close_form_wrapper(self):
        self.w(u'</div>')
        self.w(u'</div>')

    def view_form(self, divid, value, form=None, renderer=None,
                  edit_related=False, add_related=False, delete_related=False):
        self._open_form_wrapper(divid, value, form, renderer,
                                edit_related, add_related, delete_related)
        args = form.event_args.copy()
        self._edit_action(divid, args, edit_related, add_related, delete_related)
        self._add_action(divid, args, edit_related, add_related, delete_related)
        self._del_action(divid, args, edit_related, add_related, delete_related)
        self._close_form_wrapper()


ClickAndEditFormView = class_renamed('ClickAndEditFormView', AutoClickAndEditFormView)