cubicweb/web/views/editcontroller.py
changeset 11057 0b59724cb3f2
parent 11033 63d860a14a17
child 11129 97095348b3ee
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
       
     1 # copyright 2003-2013 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 """The edit controller, automatically handling entity form submitting"""
       
    19 
       
    20 __docformat__ = "restructuredtext en"
       
    21 
       
    22 from warnings import warn
       
    23 from collections import defaultdict
       
    24 
       
    25 from datetime import datetime
       
    26 
       
    27 from six import text_type
       
    28 
       
    29 from logilab.common.deprecation import deprecated
       
    30 from logilab.common.graph import ordered_nodes
       
    31 
       
    32 from rql.utils import rqlvar_maker
       
    33 
       
    34 from cubicweb import _, Binary, ValidationError
       
    35 from cubicweb.view import EntityAdapter
       
    36 from cubicweb.predicates import is_instance
       
    37 from cubicweb.web import (INTERNAL_FIELD_VALUE, RequestError, NothingToEdit,
       
    38                           ProcessFormError)
       
    39 from cubicweb.web.views import basecontrollers, autoform
       
    40 
       
    41 
       
    42 class IEditControlAdapter(EntityAdapter):
       
    43     __regid__ = 'IEditControl'
       
    44     __select__ = is_instance('Any')
       
    45 
       
    46     def __init__(self, _cw, **kwargs):
       
    47         if self.__class__ is not IEditControlAdapter:
       
    48             warn('[3.14] IEditControlAdapter is deprecated, override EditController'
       
    49                  ' using match_edited_type or match_form_id selectors for example.',
       
    50                  DeprecationWarning)
       
    51         super(IEditControlAdapter, self).__init__(_cw, **kwargs)
       
    52 
       
    53     def after_deletion_path(self):
       
    54         """return (path, parameters) which should be used as redirect
       
    55         information when this entity is being deleted
       
    56         """
       
    57         parent = self.entity.cw_adapt_to('IBreadCrumbs').parent_entity()
       
    58         if parent is not None:
       
    59             return parent.rest_path(), {}
       
    60         return str(self.entity.e_schema).lower(), {}
       
    61 
       
    62     def pre_web_edit(self):
       
    63         """callback called by the web editcontroller when an entity will be
       
    64         created/modified, to let a chance to do some entity specific stuff.
       
    65 
       
    66         Do nothing by default.
       
    67         """
       
    68         pass
       
    69 
       
    70 
       
    71 def valerror_eid(eid):
       
    72     try:
       
    73         return int(eid)
       
    74     except (ValueError, TypeError):
       
    75         return eid
       
    76 
       
    77 class RqlQuery(object):
       
    78     def __init__(self):
       
    79         self.edited = []
       
    80         self.restrictions = []
       
    81         self.kwargs = {}
       
    82 
       
    83     def __repr__(self):
       
    84         return ('Query <edited=%r restrictions=%r kwargs=%r>' % (
       
    85             self.edited, self.restrictions, self.kwargs))
       
    86 
       
    87     def insert_query(self, etype):
       
    88         if self.edited:
       
    89             rql = 'INSERT %s X: %s' % (etype, ','.join(self.edited))
       
    90         else:
       
    91             rql = 'INSERT %s X' % etype
       
    92         if self.restrictions:
       
    93             rql += ' WHERE %s' % ','.join(self.restrictions)
       
    94         return rql
       
    95 
       
    96     def update_query(self, eid):
       
    97         varmaker = rqlvar_maker()
       
    98         var = next(varmaker)
       
    99         while var in self.kwargs:
       
   100             var = next(varmaker)
       
   101         rql = 'SET %s WHERE X eid %%(%s)s' % (','.join(self.edited), var)
       
   102         if self.restrictions:
       
   103             rql += ', %s' % ','.join(self.restrictions)
       
   104         self.kwargs[var] = eid
       
   105         return rql
       
   106 
       
   107     def set_attribute(self, attr, value):
       
   108         self.kwargs[attr] = value
       
   109         self.edited.append('X %s %%(%s)s' % (attr, attr))
       
   110 
       
   111     def set_inlined(self, relation, value):
       
   112         self.kwargs[relation] = value
       
   113         self.edited.append('X %s %s' % (relation, relation.upper()))
       
   114         self.restrictions.append('%s eid %%(%s)s' % (relation.upper(), relation))
       
   115 
       
   116 
       
   117 class EditController(basecontrollers.ViewController):
       
   118     __regid__ = 'edit'
       
   119 
       
   120     def publish(self, rset=None):
       
   121         """edit / create / copy / delete entity / relations"""
       
   122         for key in self._cw.form:
       
   123             # There should be 0 or 1 action
       
   124             if key.startswith('__action_'):
       
   125                 cbname = key[1:]
       
   126                 try:
       
   127                     callback = getattr(self, cbname)
       
   128                 except AttributeError:
       
   129                     raise RequestError(self._cw._('invalid action %r' % key))
       
   130                 else:
       
   131                     return callback()
       
   132         self._default_publish()
       
   133         self.reset()
       
   134 
       
   135     def _ordered_formparams(self):
       
   136         """ Return form parameters dictionaries for each edited entity.
       
   137 
       
   138         We ensure that entities can be created in this order accounting for
       
   139         mandatory inlined relations.
       
   140         """
       
   141         req = self._cw
       
   142         graph = {}
       
   143         get_rschema = self._cw.vreg.schema.rschema
       
   144         # minparams = 2, because at least __type and eid are needed
       
   145         values_by_eid = dict((eid, req.extract_entity_params(eid, minparams=2))
       
   146                              for eid in req.edited_eids())
       
   147         # iterate over all the edited entities
       
   148         for eid, values in values_by_eid.items():
       
   149             # add eid to the dependency graph
       
   150             graph.setdefault(eid, set())
       
   151             # search entity's edited fields for mandatory inlined relation
       
   152             for param in values['_cw_entity_fields'].split(','):
       
   153                 try:
       
   154                     rtype, role = param.split('-')
       
   155                 except ValueError:
       
   156                     # e.g. param='__type'
       
   157                     continue
       
   158                 rschema = get_rschema(rtype)
       
   159                 if rschema.inlined:
       
   160                     for target in rschema.targets(values['__type'], role):
       
   161                         rdef = rschema.role_rdef(values['__type'], target, role)
       
   162                         # if cardinality is 1 and if the target entity is being
       
   163                         # simultaneously edited, the current entity must be
       
   164                         # created before the target one
       
   165                         if rdef.cardinality[0 if role == 'subject' else 1] == '1':
       
   166                             # use .get since param may be unspecified (though it will usually lead
       
   167                             # to a validation error later)
       
   168                             target_eid = values.get(param)
       
   169                             if target_eid in values_by_eid:
       
   170                                 # add dependency from the target entity to the
       
   171                                 # current one
       
   172                                 if role == 'object':
       
   173                                     graph.setdefault(target_eid, set()).add(eid)
       
   174                                 else:
       
   175                                     graph.setdefault(eid, set()).add(target_eid)
       
   176                                 break
       
   177         for eid in reversed(ordered_nodes(graph)):
       
   178             yield values_by_eid[eid]
       
   179 
       
   180     def _default_publish(self):
       
   181         req = self._cw
       
   182         self.errors = []
       
   183         self.relations_rql = []
       
   184         form = req.form
       
   185         # so we're able to know the main entity from the repository side
       
   186         if '__maineid' in form:
       
   187             req.transaction_data['__maineid'] = form['__maineid']
       
   188         # no specific action, generic edition
       
   189         self._to_create = req.data['eidmap'] = {}
       
   190         # those two data variables are used to handle relation from/to entities
       
   191         # which doesn't exist at time where the entity is edited and that
       
   192         # deserves special treatment
       
   193         req.data['pending_inlined'] = defaultdict(set)
       
   194         req.data['pending_others'] = set()
       
   195         try:
       
   196             for formparams in self._ordered_formparams():
       
   197                 eid = self.edit_entity(formparams)
       
   198         except (RequestError, NothingToEdit) as ex:
       
   199             if '__linkto' in req.form and 'eid' in req.form:
       
   200                 self.execute_linkto()
       
   201             elif not ('__delete' in req.form or '__insert' in req.form):
       
   202                 raise ValidationError(None, {None: text_type(ex)})
       
   203         # all pending inlined relations to newly created entities have been
       
   204         # treated now (pop to ensure there are no attempt to add new ones)
       
   205         pending_inlined = req.data.pop('pending_inlined')
       
   206         assert not pending_inlined, pending_inlined
       
   207         # handle all other remaining relations now
       
   208         for form_, field in req.data.pop('pending_others'):
       
   209             self.handle_formfield(form_, field)
       
   210         # then execute rql to set all relations
       
   211         for querydef in self.relations_rql:
       
   212             self._cw.execute(*querydef)
       
   213         # XXX this processes *all* pending operations of *all* entities
       
   214         if '__delete' in req.form:
       
   215             todelete = req.list_form_param('__delete', req.form, pop=True)
       
   216             if todelete:
       
   217                 autoform.delete_relations(self._cw, todelete)
       
   218         self._cw.remove_pending_operations()
       
   219         if self.errors:
       
   220             errors = dict((f.name, text_type(ex)) for f, ex in self.errors)
       
   221             raise ValidationError(valerror_eid(form.get('__maineid')), errors)
       
   222 
       
   223     def _insert_entity(self, etype, eid, rqlquery):
       
   224         rql = rqlquery.insert_query(etype)
       
   225         try:
       
   226             entity = self._cw.execute(rql, rqlquery.kwargs).get_entity(0, 0)
       
   227             neweid = entity.eid
       
   228         except ValidationError as ex:
       
   229             self._to_create[eid] = ex.entity
       
   230             if self._cw.ajax_request: # XXX (syt) why?
       
   231                 ex.entity = eid
       
   232             raise
       
   233         self._to_create[eid] = neweid
       
   234         return neweid
       
   235 
       
   236     def _update_entity(self, eid, rqlquery):
       
   237         self._cw.execute(rqlquery.update_query(eid), rqlquery.kwargs)
       
   238 
       
   239     def edit_entity(self, formparams, multiple=False):
       
   240         """edit / create / copy an entity and return its eid"""
       
   241         req = self._cw
       
   242         etype = formparams['__type']
       
   243         entity = req.vreg['etypes'].etype_class(etype)(req)
       
   244         entity.eid = valerror_eid(formparams['eid'])
       
   245         is_main_entity = req.form.get('__maineid') == formparams['eid']
       
   246         # let a chance to do some entity specific stuff
       
   247         entity.cw_adapt_to('IEditControl').pre_web_edit()
       
   248         # create a rql query from parameters
       
   249         rqlquery = RqlQuery()
       
   250         # process inlined relations at the same time as attributes
       
   251         # this will generate less rql queries and might be useful in
       
   252         # a few dark corners
       
   253         if is_main_entity:
       
   254             formid = req.form.get('__form_id', 'edition')
       
   255         else:
       
   256             # XXX inlined forms formid should be saved in a different formparams entry
       
   257             # inbetween, use cubicweb standard formid for inlined forms
       
   258             formid = 'edition'
       
   259         form = req.vreg['forms'].select(formid, req, entity=entity)
       
   260         eid = form.actual_eid(entity.eid)
       
   261         editedfields = formparams['_cw_entity_fields']
       
   262         form.formvalues = {} # init fields value cache
       
   263         for field in form.iter_modified_fields(editedfields, entity):
       
   264             self.handle_formfield(form, field, rqlquery)
       
   265         # if there are some inlined field which were waiting for this entity's
       
   266         # creation, add relevant data to the rqlquery
       
   267         for form_, field in req.data['pending_inlined'].pop(entity.eid, ()):
       
   268             rqlquery.set_inlined(field.name, form_.edited_entity.eid)
       
   269         if self.errors:
       
   270             errors = dict((f.role_name(), text_type(ex)) for f, ex in self.errors)
       
   271             raise ValidationError(valerror_eid(entity.eid), errors)
       
   272         if eid is None: # creation or copy
       
   273             entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
       
   274         elif rqlquery.edited: # edition of an existant entity
       
   275             self.check_concurrent_edition(formparams, eid)
       
   276             self._update_entity(eid, rqlquery)
       
   277         if is_main_entity:
       
   278             self.notify_edited(entity)
       
   279         if '__delete' in formparams:
       
   280             # XXX deprecate?
       
   281             todelete = req.list_form_param('__delete', formparams, pop=True)
       
   282             autoform.delete_relations(req, todelete)
       
   283         if '__cloned_eid' in formparams:
       
   284             entity.copy_relations(int(formparams['__cloned_eid']))
       
   285         if is_main_entity: # only execute linkto for the main entity
       
   286             self.execute_linkto(entity.eid)
       
   287         return eid
       
   288 
       
   289     def handle_formfield(self, form, field, rqlquery=None):
       
   290         eschema = form.edited_entity.e_schema
       
   291         try:
       
   292             for field, value in field.process_posted(form):
       
   293                 if not (
       
   294                     (field.role == 'subject' and field.name in eschema.subjrels)
       
   295                     or
       
   296                     (field.role == 'object' and field.name in eschema.objrels)):
       
   297                     continue
       
   298                 rschema = self._cw.vreg.schema.rschema(field.name)
       
   299                 if rschema.final:
       
   300                     rqlquery.set_attribute(field.name, value)
       
   301                 else:
       
   302                     if form.edited_entity.has_eid():
       
   303                         origvalues = set(entity.eid for entity in form.edited_entity.related(field.name, field.role, entities=True))
       
   304                     else:
       
   305                         origvalues = set()
       
   306                     if value is None or value == origvalues:
       
   307                         continue # not edited / not modified / to do later
       
   308                     if rschema.inlined and rqlquery is not None and field.role == 'subject':
       
   309                         self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
       
   310                     elif form.edited_entity.has_eid():
       
   311                         self.handle_relation(form, field, value, origvalues)
       
   312                     else:
       
   313                         form._cw.data['pending_others'].add( (form, field) )
       
   314         except ProcessFormError as exc:
       
   315             self.errors.append((field, exc))
       
   316 
       
   317     def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
       
   318         """handle edition for the (rschema, x) relation of the given entity
       
   319         """
       
   320         if values:
       
   321             rqlquery.set_inlined(field.name, next(iter(values)))
       
   322         elif form.edited_entity.has_eid():
       
   323             self.handle_relation(form, field, values, origvalues)
       
   324 
       
   325     def handle_relation(self, form, field, values, origvalues):
       
   326         """handle edition for the (rschema, x) relation of the given entity
       
   327         """
       
   328         etype = form.edited_entity.e_schema
       
   329         rschema = self._cw.vreg.schema.rschema(field.name)
       
   330         if field.role == 'subject':
       
   331             desttype = rschema.objects(etype)[0]
       
   332             card = rschema.rdef(etype, desttype).cardinality[0]
       
   333             subjvar, objvar = 'X', 'Y'
       
   334         else:
       
   335             desttype = rschema.subjects(etype)[0]
       
   336             card = rschema.rdef(desttype, etype).cardinality[1]
       
   337             subjvar, objvar = 'Y', 'X'
       
   338         eid = form.edited_entity.eid
       
   339         if field.role == 'object' or not rschema.inlined or not values:
       
   340             # this is not an inlined relation or no values specified,
       
   341             # explicty remove relations
       
   342             rql = 'DELETE %s %s %s WHERE X eid %%(x)s, Y eid %%(y)s' % (
       
   343                 subjvar, rschema, objvar)
       
   344             for reid in origvalues.difference(values):
       
   345                 self.relations_rql.append((rql, {'x': eid, 'y': reid}))
       
   346         seteids = values.difference(origvalues)
       
   347         if seteids:
       
   348             rql = 'SET %s %s %s WHERE X eid %%(x)s, Y eid %%(y)s' % (
       
   349                 subjvar, rschema, objvar)
       
   350             for reid in seteids:
       
   351                 self.relations_rql.append((rql, {'x': eid, 'y': reid}))
       
   352 
       
   353     def delete_entities(self, eidtypes):
       
   354         """delete entities from the repository"""
       
   355         redirect_info = set()
       
   356         eidtypes = tuple(eidtypes)
       
   357         for eid, etype in eidtypes:
       
   358             entity = self._cw.entity_from_eid(eid, etype)
       
   359             path, params = entity.cw_adapt_to('IEditControl').after_deletion_path()
       
   360             redirect_info.add( (path, tuple(params.items())) )
       
   361             entity.cw_delete()
       
   362         if len(redirect_info) > 1:
       
   363             # In the face of ambiguity, refuse the temptation to guess.
       
   364             self._after_deletion_path = 'view', ()
       
   365         else:
       
   366             self._after_deletion_path = next(iter(redirect_info))
       
   367         if len(eidtypes) > 1:
       
   368             self._cw.set_message(self._cw._('entities deleted'))
       
   369         else:
       
   370             self._cw.set_message(self._cw._('entity deleted'))
       
   371 
       
   372 
       
   373     def check_concurrent_edition(self, formparams, eid):
       
   374         req = self._cw
       
   375         try:
       
   376             form_ts = datetime.utcfromtimestamp(float(formparams['__form_generation_time']))
       
   377         except KeyError:
       
   378             # Backward and tests compatibility : if no timestamp consider edition OK
       
   379             return
       
   380         if req.execute("Any X WHERE X modification_date > %(fts)s, X eid %(eid)s",
       
   381                        {'eid': eid, 'fts': form_ts}):
       
   382             # We only mark the message for translation but the actual
       
   383             # translation will be handled by the Validation mechanism...
       
   384             msg = _("Entity %(eid)s has changed since you started to edit it."
       
   385                     " Reload the page and reapply your changes.")
       
   386             # ... this is why we pass the formats' dict as a third argument.
       
   387             raise ValidationError(eid, {None: msg}, {'eid' : eid})
       
   388 
       
   389     def _action_apply(self):
       
   390         self._default_publish()
       
   391         self.reset()
       
   392 
       
   393     def _action_delete(self):
       
   394         self.delete_entities(self._cw.edited_eids(withtype=True))
       
   395         return self.reset()