web/views/editcontroller.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Fri, 27 Nov 2009 09:10:40 +0100
branchstable
changeset 3921 4f24b6ef3da9
parent 3689 deb13e88e037
child 3924 4347654979e8
permissions -rw-r--r--
edit controller now informs server side about its main eid through transaction data

"""The edit controller, handling form submitting.

:organization: Logilab
:copyright: 2001-2009 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"

from decimal import Decimal

from rql.utils import rqlvar_maker

from cubicweb import Binary, ValidationError, typed_eid
from cubicweb.web import INTERNAL_FIELD_VALUE, RequestError, NothingToEdit
from cubicweb.web.controller import parse_relations_descr
from cubicweb.web.views.basecontrollers import ViewController


class ToDoLater(Exception):
    """exception used in the edit controller to indicate that a relation
    can't be handled right now and have to be handled later
    """

class EditController(ViewController):
    id = 'edit'

    def publish(self, rset=None):
        """edit / create / copy / delete entity / relations"""
        for key in self.req.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.req._('invalid action %r' % key))
                else:
                    return callback()
        self._default_publish()
        self.reset()

    def _default_publish(self):
        req = self.req
        form = req.form
        # so we're able to know the main entity from the repository side
        if '__maineid' in form:
            req.set_shared_data('__maineid', typed_eid(form['__maineid']),
                                querydata=True)
        # no specific action, generic edition
        self._to_create = req.data['eidmap'] = {}
        self._pending_relations = []
        todelete = self.req.get_pending_deletes()
        toinsert = self.req.get_pending_inserts()
        try:
            methodname = form.pop('__method', None)
            for eid in req.edited_eids():
                # __type and eid
                formparams = req.extract_entity_params(eid, minparams=2)
                if methodname is not None:
                    entity = req.entity_from_eid(eid)
                    method = getattr(entity, methodname)
                    method(formparams)
                eid = self.edit_entity(formparams)
        except (RequestError, NothingToEdit):
            if '__linkto' in form and 'eid' in form:
                self.execute_linkto()
            elif not ('__delete' in form or '__insert' in form or todelete or toinsert):
                raise ValidationError(None, {None: req._('nothing to edit')})
        # handle relations in newly created entities
        if self._pending_relations:
            for rschema, formparams, x, entity in self._pending_relations:
                self.handle_relation(rschema, formparams, x, entity, True)

        # XXX this processes *all* pending operations of *all* entities
        if form.has_key('__delete'):
            todelete += req.list_form_param('__delete', form, pop=True)
        if todelete:
            self.delete_relations(parse_relations_descr(todelete))
        if form.has_key('__insert'):
            toinsert = req.list_form_param('__insert', form, pop=True)
        if toinsert:
            self.insert_relations(parse_relations_descr(toinsert))
        self.req.remove_pending_operations()

    def edit_entity(self, formparams, multiple=False):
        """edit / create / copy an entity and return its eid"""
        etype = formparams['__type']
        entity = self.vreg['etypes'].etype_class(etype)(self.req)
        entity.eid = eid = self._get_eid(formparams['eid'])
        edited = self.req.form.get('__maineid') == formparams['eid']
        # let a chance to do some entity specific stuff.
        entity.pre_web_edit()
        # create a rql query from parameters
        self.relations = []
        self.restrictions = []
        # process inlined relations at the same time as attributes
        # this is required by some external source such as the svn source which
        # needs some information provided by those inlined relation. Moreover
        # this will generate less write queries.
        for rschema in entity.e_schema.subject_relations():
            if rschema.final:
                self.handle_attribute(entity, rschema, formparams)
            elif rschema.inlined:
                self.handle_inlined_relation(rschema, formparams, entity)
        execute = self.req.execute
        if eid is None: # creation or copy
            if self.relations:
                rql = 'INSERT %s X: %s' % (etype, ','.join(self.relations))
            else:
                rql = 'INSERT %s X' % etype
            if self.restrictions:
                rql += ' WHERE %s' % ','.join(self.restrictions)
            try:
                # get the new entity (in some cases, the type might have
                # changed as for the File --> Image mutation)
                entity = execute(rql, formparams).get_entity(0, 0)
                eid = entity.eid
            except ValidationError, ex:
                self._to_create[formparams['eid']] = ex.entity
                if self.req.json_request: # XXX (syt) why?
                    ex.entity = formparams['eid']
                raise
            self._to_create[formparams['eid']] = eid
        elif self.relations: # edition of an existant entity
            varmaker = rqlvar_maker()
            var = varmaker.next()
            while var in formparams:
                var = varmaker.next()
            rql = 'SET %s WHERE X eid %%(%s)s' % (','.join(self.relations), var)
            if self.restrictions:
                rql += ', %s' % ','.join(self.restrictions)
            formparams[var] = eid
            execute(rql, formparams)
        for rschema in entity.e_schema.subject_relations():
            if rschema.final or rschema.inlined:
                continue
            self.handle_relation(rschema, formparams, 'subject', entity)
        for rschema in entity.e_schema.object_relations():
            if rschema.final:
                continue
            self.handle_relation(rschema, formparams, 'object', entity)
        if edited:
            self.notify_edited(entity)
        if formparams.has_key('__delete'):
            todelete = self.req.list_form_param('__delete', formparams, pop=True)
            self.delete_relations(parse_relations_descr(todelete))
        if formparams.has_key('__cloned_eid'):
            entity.copy_relations(typed_eid(formparams['__cloned_eid']))
        if formparams.has_key('__insert'):
            toinsert = self.req.list_form_param('__insert', formparams, pop=True)
            self.insert_relations(parse_relations_descr(toinsert))
        if edited: # only execute linkto for the main entity
            self.execute_linkto(eid)
        return eid

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

    def _action_cancel(self):
        errorurl = self.req.form.get('__errorurl')
        if errorurl:
            self.req.cancel_edition(errorurl)
        self.req.message = self.req._('edit canceled')
        return self.reset()

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

    def _needs_edition(self, rtype, formparams, entity):
        """returns True and and the new value if `rtype` was edited"""
        editkey = 'edits-%s' % rtype
        if not editkey in formparams:
            return False, None # not edited
        value = formparams.get(rtype) or None
        if entity.has_eid() and (formparams.get(editkey) or None) == value:
            return False, None # not modified
        if value == INTERNAL_FIELD_VALUE:
            value = None
        return True, value

    def handle_attribute(self, entity, rschema, formparams):
        """append to `relations` part of the rql query to edit the
        attribute described by the given schema if necessary
        """
        attr = rschema.type
        edition_needed, value = self._needs_edition(attr, formparams, entity)
        if not edition_needed:
            return
        # test if entity class defines a special handler for this attribute
        custom_edit = getattr(entity, 'custom_%s_edit' % attr, None)
        if custom_edit:
            custom_edit(formparams, value, self.relations)
            return
        attrtype = rschema.objects(entity.e_schema)[0].type
        # on checkbox or selection, the field may not be in params
        # NOTE: raising ValidationError here is not a good solution because
        #       we can't gather all errors at once. Hopefully, the new 3.6.x
        #       form handling will fix that
        if attrtype == 'Boolean':
            value = bool(value)
        elif attrtype == 'Decimal':
            value = Decimal(value)
        elif attrtype == 'Bytes':
            # if it is a file, transport it using a Binary (StringIO)
            # XXX later __detach is for the new widget system, the former is to
            # be removed once web/widgets.py has been dropped
            if formparams.has_key('__%s_detach' % attr) or formparams.has_key('%s__detach' % attr):
                # drop current file value
                value = None
            # no need to check value when nor explicit detach nor new file
            # submitted, since it will think the attribute is not modified
            elif isinstance(value, unicode):
                # file modified using a text widget
                encoding = entity.attr_metadata(attr, 'encoding')
                value = Binary(value.encode(encoding))
            elif value:
                # value is a  3-uple (filename, mimetype, stream)
                val = Binary(value[2].read())
                if not val.getvalue(): # usually an unexistant file
                    value = None
                else:
                    val.filename = value[0]
                    # ignore browser submitted MIME type since it may be buggy
                    # XXX add a config option to tell if we should consider it
                    # or not?
                    #if entity.e_schema.has_metadata(attr, 'format'):
                    #    key = '%s_format' % attr
                    #    formparams[key] = value[1]
                    #    self.relations.append('X %s_format %%(%s)s'
                    #                          % (attr, key))
                    # XXX suppose a File compatible schema
                    if 'name' in entity.e_schema.subjrels \
                           and not formparams.get('name'):
                        formparams['name'] = value[0]
                        self.relations.append('X name %(name)s')
                    value = val
            else:
                # no specified value, skip
                return
        elif value is not None:
            if attrtype == 'Int':
                try:
                    value = int(value)
                except ValueError:
                    raise ValidationError(entity.eid,
                                          {attr: self.req._("invalid integer value")})
            elif attrtype == 'Float':
                try:
                    value = float(value)
                except ValueError:
                    raise ValidationError(entity.eid,
                                          {attr: self.req._("invalid float value")})
            elif attrtype in ('Date', 'Datetime', 'Time'):
                try:
                    value = self.parse_datetime(value, attrtype)
                except ValueError:
                    raise ValidationError(entity.eid,
                                          {attr: self.req._("invalid date")})
            elif attrtype == 'Password':
                # check confirmation (see PasswordWidget for confirmation field name)
                confirmval = formparams.get(attr + '-confirm')
                if confirmval != value:
                    raise ValidationError(entity.eid,
                                          {attr: self.req._("password and confirmation don't match")})
                # password should *always* be utf8 encoded
                value = value.encode('UTF8')
            else:
                # strip strings
                value = value.strip()
        elif attrtype == 'Password':
            # skip None password
            return # unset password
        formparams[attr] = value
        self.relations.append('X %s %%(%s)s' % (attr, attr))

    def _relation_values(self, rschema, formparams, x, entity, late=False):
        """handle edition for the (rschema, x) relation of the given entity
        """
        rtype = rschema.type
        editkey = 'edit%s-%s' % (x[0], rtype)
        if not editkey in formparams:
            return # not edited
        try:
            values = self._linked_eids(self.req.list_form_param(rtype, formparams), late)
        except ToDoLater:
            self._pending_relations.append((rschema, formparams, x, entity))
            return
        origvalues = set(typed_eid(eid) for eid in self.req.list_form_param(editkey, formparams))
        return values, origvalues

    def handle_inlined_relation(self, rschema, formparams, entity, late=False):
        """handle edition for the (rschema, x) relation of the given entity
        """
        try:
            values, origvalues = self._relation_values(rschema, formparams,
                                                       'subject', entity, late)
        except TypeError:
            return # not edited / to do later
        if values == origvalues:
            return # not modified
        attr = str(rschema)
        if values:
            formparams[attr] = iter(values).next()
            self.relations.append('X %s %s' % (attr, attr.upper()))
            self.restrictions.append('%s eid %%(%s)s' % (attr.upper(), attr))
        elif entity.has_eid():
            self.handle_relation(rschema, formparams, 'subject', entity, late)

    def handle_relation(self, rschema, formparams, x, entity, late=False):
        """handle edition for the (rschema, x) relation of the given entity
        """
        try:
            values, origvalues = self._relation_values(rschema, formparams, x,
                                                       entity, late)
        except TypeError:
            return # not edited / to do later
        etype = entity.e_schema
        if values == origvalues:
            return # not modified
        if x == 'subject':
            desttype = rschema.objects(etype)[0]
            card = rschema.rproperty(etype, desttype, 'cardinality')[0]
            subjvar, objvar = 'X', 'Y'
        else:
            desttype = rschema.subjects(etype)[0]
            card = rschema.rproperty(desttype, etype, 'cardinality')[1]
            subjvar, objvar = 'Y', 'X'
        eid = entity.eid
        if x == '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.req.execute(rql, {'x': eid, 'y': reid}, ('x', 'y'))
        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.req.execute(rql, {'x': eid, 'y': reid}, ('x', 'y'))

    def _get_eid(self, eid):
        # should be either an int (existant entity) or a variable (to be
        # created entity)
        assert eid or eid == 0, repr(eid) # 0 is a valid eid
        try:
            return typed_eid(eid)
        except ValueError:
            try:
                return self._to_create[eid]
            except KeyError:
                self._to_create[eid] = None
                return None

    def _linked_eids(self, eids, late=False):
        """return a list of eids if they are all known, else raise ToDoLater
        """
        result = set()
        for eid in eids:
            if not eid: # AutoCompletionWidget
                continue
            eid = self._get_eid(eid)
            if eid is None:
                if not late:
                    raise ToDoLater()
                # eid is still None while it's already a late call
                # this mean that the associated entity has not been created
                raise Exception('duh')
            result.add(eid)
        return result