web/views/editcontroller.py
author Rémi Cardona <remi.cardona@logilab.fr>, Julien Cristau <julien.cristau@logilab.fr>
Thu, 26 Nov 2015 11:30:54 +0100
changeset 10935 049209b9e9d6
parent 10932 cb217b2b3463
child 10969 b4de8b1cc135
permissions -rw-r--r--
[qunit] stop dealing with filesystem paths qunit tests need a few things served by cubicweb: - qunit itself, which is now handled by CWDevtoolsStaticController (serving files in cubicweb/devtools/data) - standard cubicweb or cubes data files, handled by the DataController - the tests themselves and their dependencies. These can live in <apphome>/data or <apphome>/static and be served by one of the STATIC_CONTROLLERS This avoids having to guess in CWSoftwareRootStaticController where to serve things from (some files may be installed, others are in the source tree), and should hopefully make it possible to have these tests pass when using tox, and to write qunit tests for cubes, outside of cubicweb itself. This requires modifying the tests to only declare URL paths instead of filesystem paths, and moving support files below test/data/static.

# copyright 2003-2013 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/>.
"""The edit controller, automatically handling entity form submitting"""

__docformat__ = "restructuredtext en"

from warnings import warn
from collections import defaultdict

from datetime import datetime

from six import text_type

from logilab.common.deprecation import deprecated
from logilab.common.graph import ordered_nodes

from rql.utils import rqlvar_maker

from cubicweb import Binary, ValidationError
from cubicweb.view import EntityAdapter
from cubicweb.predicates import is_instance
from cubicweb.web import (INTERNAL_FIELD_VALUE, RequestError, NothingToEdit,
                          ProcessFormError)
from cubicweb.web.views import basecontrollers, autoform


class IEditControlAdapter(EntityAdapter):
    __regid__ = 'IEditControl'
    __select__ = is_instance('Any')

    def __init__(self, _cw, **kwargs):
        if self.__class__ is not IEditControlAdapter:
            warn('[3.14] IEditControlAdapter is deprecated, override EditController'
                 ' using match_edited_type or match_form_id selectors for example.',
                 DeprecationWarning)
        super(IEditControlAdapter, self).__init__(_cw, **kwargs)

    def after_deletion_path(self):
        """return (path, parameters) which should be used as redirect
        information when this entity is being deleted
        """
        parent = self.entity.cw_adapt_to('IBreadCrumbs').parent_entity()
        if parent is not None:
            return parent.rest_path(), {}
        return str(self.entity.e_schema).lower(), {}

    def pre_web_edit(self):
        """callback called by the web editcontroller when an entity will be
        created/modified, to let a chance to do some entity specific stuff.

        Do nothing by default.
        """
        pass


def valerror_eid(eid):
    try:
        return int(eid)
    except (ValueError, TypeError):
        return eid

class RqlQuery(object):
    def __init__(self):
        self.edited = []
        self.restrictions = []
        self.kwargs = {}

    def __repr__(self):
        return ('Query <edited=%r restrictions=%r kwargs=%r>' % (
            self.edited, self.restrictions, self.kwargs))

    def insert_query(self, etype):
        if self.edited:
            rql = 'INSERT %s X: %s' % (etype, ','.join(self.edited))
        else:
            rql = 'INSERT %s X' % etype
        if self.restrictions:
            rql += ' WHERE %s' % ','.join(self.restrictions)
        return rql

    def update_query(self, eid):
        varmaker = rqlvar_maker()
        var = next(varmaker)
        while var in self.kwargs:
            var = next(varmaker)
        rql = 'SET %s WHERE X eid %%(%s)s' % (','.join(self.edited), var)
        if self.restrictions:
            rql += ', %s' % ','.join(self.restrictions)
        self.kwargs[var] = eid
        return rql

    def set_attribute(self, attr, value):
        self.kwargs[attr] = value
        self.edited.append('X %s %%(%s)s' % (attr, attr))

    def set_inlined(self, relation, value):
        self.kwargs[relation] = value
        self.edited.append('X %s %s' % (relation, relation.upper()))
        self.restrictions.append('%s eid %%(%s)s' % (relation.upper(), relation))


class EditController(basecontrollers.ViewController):
    __regid__ = 'edit'

    def publish(self, rset=None):
        """edit / create / copy / delete entity / relations"""
        for key in self._cw.form:
            # There should be 0 or 1 action
            if key.startswith('__action_'):
                cbname = key[1:]
                try:
                    callback = getattr(self, cbname)
                except AttributeError:
                    raise RequestError(self._cw._('invalid action %r' % key))
                else:
                    return callback()
        self._default_publish()
        self.reset()

    def _ordered_formparams(self):
        """ Return form parameters dictionaries for each edited entity.

        We ensure that entities can be created in this order accounting for
        mandatory inlined relations.
        """
        req = self._cw
        graph = {}
        get_rschema = self._cw.vreg.schema.rschema
        # minparams = 2, because at least __type and eid are needed
        values_by_eid = dict((eid, req.extract_entity_params(eid, minparams=2))
                             for eid in req.edited_eids())
        # iterate over all the edited entities
        for eid, values in values_by_eid.items():
            # add eid to the dependency graph
            graph.setdefault(eid, set())
            # search entity's edited fields for mandatory inlined relation
            for param in values['_cw_entity_fields'].split(','):
                try:
                    rtype, role = param.split('-')
                except ValueError:
                    # e.g. param='__type'
                    continue
                rschema = get_rschema(rtype)
                if rschema.inlined:
                    for target in rschema.targets(values['__type'], role):
                        rdef = rschema.role_rdef(values['__type'], target, role)
                        # if cardinality is 1 and if the target entity is being
                        # simultaneously edited, the current entity must be
                        # created before the target one
                        if rdef.cardinality[0 if role == 'subject' else 1] == '1':
                            # use .get since param may be unspecified (though it will usually lead
                            # to a validation error later)
                            target_eid = values.get(param)
                            if target_eid in values_by_eid:
                                # add dependency from the target entity to the
                                # current one
                                if role == 'object':
                                    graph.setdefault(target_eid, set()).add(eid)
                                else:
                                    graph.setdefault(eid, set()).add(target_eid)
                                break
        for eid in reversed(ordered_nodes(graph)):
            yield values_by_eid[eid]

    def _default_publish(self):
        req = self._cw
        self.errors = []
        self.relations_rql = []
        form = req.form
        # so we're able to know the main entity from the repository side
        if '__maineid' in form:
            req.transaction_data['__maineid'] = form['__maineid']
        # no specific action, generic edition
        self._to_create = req.data['eidmap'] = {}
        # those two data variables are used to handle relation from/to entities
        # which doesn't exist at time where the entity is edited and that
        # deserves special treatment
        req.data['pending_inlined'] = defaultdict(set)
        req.data['pending_others'] = set()
        try:
            for formparams in self._ordered_formparams():
                eid = self.edit_entity(formparams)
        except (RequestError, NothingToEdit) as ex:
            if '__linkto' in req.form and 'eid' in req.form:
                self.execute_linkto()
            elif not ('__delete' in req.form or '__insert' in req.form):
                raise ValidationError(None, {None: text_type(ex)})
        # all pending inlined relations to newly created entities have been
        # treated now (pop to ensure there are no attempt to add new ones)
        pending_inlined = req.data.pop('pending_inlined')
        assert not pending_inlined, pending_inlined
        # handle all other remaining relations now
        for form_, field in req.data.pop('pending_others'):
            self.handle_formfield(form_, field)
        # then execute rql to set all relations
        for querydef in self.relations_rql:
            self._cw.execute(*querydef)
        # XXX this processes *all* pending operations of *all* entities
        if '__delete' in req.form:
            todelete = req.list_form_param('__delete', req.form, pop=True)
            if todelete:
                autoform.delete_relations(self._cw, todelete)
        self._cw.remove_pending_operations()
        if self.errors:
            errors = dict((f.name, text_type(ex)) for f, ex in self.errors)
            raise ValidationError(valerror_eid(form.get('__maineid')), errors)

    def _insert_entity(self, etype, eid, rqlquery):
        rql = rqlquery.insert_query(etype)
        try:
            entity = self._cw.execute(rql, rqlquery.kwargs).get_entity(0, 0)
            neweid = entity.eid
        except ValidationError as ex:
            self._to_create[eid] = ex.entity
            if self._cw.ajax_request: # XXX (syt) why?
                ex.entity = eid
            raise
        self._to_create[eid] = neweid
        return neweid

    def _update_entity(self, eid, rqlquery):
        self._cw.execute(rqlquery.update_query(eid), rqlquery.kwargs)

    def edit_entity(self, formparams, multiple=False):
        """edit / create / copy an entity and return its eid"""
        req = self._cw
        etype = formparams['__type']
        entity = req.vreg['etypes'].etype_class(etype)(req)
        entity.eid = valerror_eid(formparams['eid'])
        is_main_entity = req.form.get('__maineid') == formparams['eid']
        # let a chance to do some entity specific stuff
        entity.cw_adapt_to('IEditControl').pre_web_edit()
        # create a rql query from parameters
        rqlquery = RqlQuery()
        # process inlined relations at the same time as attributes
        # this will generate less rql queries and might be useful in
        # a few dark corners
        if is_main_entity:
            formid = req.form.get('__form_id', 'edition')
        else:
            # XXX inlined forms formid should be saved in a different formparams entry
            # inbetween, use cubicweb standard formid for inlined forms
            formid = 'edition'
        form = req.vreg['forms'].select(formid, req, entity=entity)
        eid = form.actual_eid(entity.eid)
        editedfields = formparams['_cw_entity_fields']
        form.formvalues = {} # init fields value cache
        for field in form.iter_modified_fields(editedfields, entity):
            self.handle_formfield(form, field, rqlquery)
        # if there are some inlined field which were waiting for this entity's
        # 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(), text_type(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.check_concurrent_edition(formparams, eid)
            self._update_entity(eid, rqlquery)
        if is_main_entity:
            self.notify_edited(entity)
        if '__delete' in formparams:
            # XXX deprecate?
            todelete = req.list_form_param('__delete', formparams, pop=True)
            autoform.delete_relations(req, todelete)
        if '__cloned_eid' in formparams:
            entity.copy_relations(int(formparams['__cloned_eid']))
        if is_main_entity: # only execute linkto for the main entity
            self.execute_linkto(entity.eid)
        return eid

    def handle_formfield(self, form, field, rqlquery=None):
        eschema = form.edited_entity.e_schema
        try:
            for field, value in field.process_posted(form):
                if not (
                    (field.role == 'subject' and field.name in eschema.subjrels)
                    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)
                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) )
        except ProcessFormError as exc:
            self.errors.append((field, exc))

    def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
        """handle edition for the (rschema, x) relation of the given entity
        """
        if values:
            rqlquery.set_inlined(field.name, next(iter(values)))
        elif form.edited_entity.has_eid():
            self.handle_relation(form, field, values, origvalues)

    def handle_relation(self, form, field, values, origvalues):
        """handle edition for the (rschema, x) relation of the given entity
        """
        etype = form.edited_entity.e_schema
        rschema = self._cw.vreg.schema.rschema(field.name)
        if field.role == 'subject':
            desttype = rschema.objects(etype)[0]
            card = rschema.rdef(etype, desttype).cardinality[0]
            subjvar, objvar = 'X', 'Y'
        else:
            desttype = rschema.subjects(etype)[0]
            card = rschema.rdef(desttype, etype).cardinality[1]
            subjvar, objvar = 'Y', 'X'
        eid = form.edited_entity.eid
        if field.role == 'object' or not rschema.inlined or not values:
            # this is not an inlined relation or no values specified,
            # explicty remove relations
            rql = 'DELETE %s %s %s WHERE X eid %%(x)s, Y eid %%(y)s' % (
                subjvar, rschema, objvar)
            for reid in origvalues.difference(values):
                self.relations_rql.append((rql, {'x': eid, 'y': reid}))
        seteids = values.difference(origvalues)
        if seteids:
            rql = 'SET %s %s %s WHERE X eid %%(x)s, Y eid %%(y)s' % (
                subjvar, rschema, objvar)
            for reid in seteids:
                self.relations_rql.append((rql, {'x': eid, 'y': reid}))

    def delete_entities(self, eidtypes):
        """delete entities from the repository"""
        redirect_info = set()
        eidtypes = tuple(eidtypes)
        for eid, etype in eidtypes:
            entity = self._cw.entity_from_eid(eid, etype)
            path, params = entity.cw_adapt_to('IEditControl').after_deletion_path()
            redirect_info.add( (path, tuple(params.items())) )
            entity.cw_delete()
        if len(redirect_info) > 1:
            # In the face of ambiguity, refuse the temptation to guess.
            self._after_deletion_path = 'view', ()
        else:
            self._after_deletion_path = next(iter(redirect_info))
        if len(eidtypes) > 1:
            self._cw.set_message(self._cw._('entities deleted'))
        else:
            self._cw.set_message(self._cw._('entity deleted'))


    def check_concurrent_edition(self, formparams, eid):
        req = self._cw
        try:
            form_ts = datetime.fromtimestamp(float(formparams['__form_generation_time']))
        except KeyError:
            # Backward and tests compatibility : if no timestamp consider edition OK
            return
        if req.execute("Any X WHERE X modification_date > %(fts)s, X eid %(eid)s",
                       {'eid': eid, 'fts': form_ts}):
            # We only mark the message for translation but the actual
            # translation will be handled by the Validation mechanism...
            msg = _("Entity %(eid)s has changed since you started to edit it."
                    " Reload the page and reapply your changes.")
            # ... this is why we pass the formats' dict as a third argument.
            raise ValidationError(eid, {None: msg}, {'eid' : eid})

    def _action_apply(self):
        self._default_publish()
        self.reset()

    def _action_delete(self):
        self.delete_entities(self._cw.edited_eids(withtype=True))
        return self.reset()