editcontroller.py 19.9 KB
Newer Older
1
# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
# 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.
#
11
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12
13
14
15
16
17
# 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/>.
18
"""The edit controller, automatically handling entity form submitting"""
Adrien Di Mascio's avatar
Adrien Di Mascio committed
19

Sylvain Thénault's avatar
Sylvain Thénault committed
20
from warnings import warn
21
from collections import defaultdict
Sylvain Thénault's avatar
Sylvain Thénault committed
22

23
24
from datetime import datetime

25
from logilab.common.graph import ordered_nodes
26

Adrien Di Mascio's avatar
Adrien Di Mascio committed
27
28
from rql.utils import rqlvar_maker

29
from cubicweb import _, ValidationError, UnknownEid
30
from cubicweb.entity import EntityAdapter
31
from cubicweb.predicates import is_instance
32
from cubicweb.web import RequestError, NothingToEdit, ProcessFormError
Sylvain Thénault's avatar
Sylvain Thénault committed
33
from cubicweb.web.views import basecontrollers, autoform
Adrien Di Mascio's avatar
Adrien Di Mascio committed
34

35
36
37

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

40
    def __init__(self, _cw, **kwargs):
41
42
43
44
        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)
45
46
        super(IEditControlAdapter, self).__init__(_cw, **kwargs)

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

56
    def pre_web_edit(self):
57
58
59
60
61
62
63
64
        """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


65
66
def valerror_eid(eid):
    try:
67
        return int(eid)
68
69
    except (ValueError, TypeError):
        return eid
Adrien Di Mascio's avatar
Adrien Di Mascio committed
70

71

72
73
74
75
76
class RqlQuery(object):
    def __init__(self):
        self.edited = []
        self.restrictions = []
        self.kwargs = {}
77
        self.canceled = False
78

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

83
    def insert_query(self, etype):
84
        assert not self.canceled
85
86
87
88
89
90
91
92
93
        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):
94
        assert not self.canceled
95
        varmaker = rqlvar_maker()
96
        var = next(varmaker)
97
        while var in self.kwargs:
98
            var = next(varmaker)
99
100
101
102
103
104
        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

105
106
107
108
109
110
111
112
113
    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))

Sylvain Thénault's avatar
cleanup    
Sylvain Thénault committed
114

Sylvain Thénault's avatar
Sylvain Thénault committed
115
class EditController(basecontrollers.ViewController):
116
    __regid__ = 'edit'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
117

118
    def publish(self, rset=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
119
        """edit / create / copy / delete entity / relations"""
120
        for key in self._cw.form:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
121
122
123
124
125
126
            # There should be 0 or 1 action
            if key.startswith('__action_'):
                cbname = key[1:]
                try:
                    callback = getattr(self, cbname)
                except AttributeError:
127
                    raise RequestError(self._cw._('invalid action %r' % key))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
128
129
130
131
132
                else:
                    return callback()
        self._default_publish()
        self.reset()

133
134
135
136
137
138
139
140
141
142
143
144
145
    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
146
        for eid, values in values_by_eid.items():
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
            # 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
163
                        if rdef.cardinality[0 if role == 'subject' else 1] == '1':
164
165
166
                            # use .get since param may be unspecified (though it will usually lead
                            # to a validation error later)
                            target_eid = values.get(param)
167
168
169
                            if target_eid in values_by_eid:
                                # add dependency from the target entity to the
                                # current one
170
171
172
173
                                if role == 'object':
                                    graph.setdefault(target_eid, set()).add(eid)
                                else:
                                    graph.setdefault(eid, set()).add(target_eid)
174
175
176
177
                                break
        for eid in reversed(ordered_nodes(graph)):
            yield values_by_eid[eid]

Adrien Di Mascio's avatar
Adrien Di Mascio committed
178
    def _default_publish(self):
179
        req = self._cw
180
181
        self.errors = []
        self.relations_rql = []
182
        form = req.form
183
184
        # so we're able to know the main entity from the repository side
        if '__maineid' in form:
185
            req.transaction_data['__maineid'] = form['__maineid']
Adrien Di Mascio's avatar
Adrien Di Mascio committed
186
187
        # no specific action, generic edition
        self._to_create = req.data['eidmap'] = {}
188
        # those three data variables are used to handle relation from/to entities
189
190
191
192
        # 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()
193
        req.data['pending_composite_delete'] = set()
194
        req.data['pending_values'] = dict()
195
        try:
196
            for formparams in self._ordered_formparams():
197
                self.edit_entity(formparams)
198
        except (RequestError, NothingToEdit) as ex:
199
200
            if '__linkto' in req.form and 'eid' in req.form:
                self.execute_linkto()
201
            elif '__delete' not in req.form:
Denis Laxalde's avatar
Denis Laxalde committed
202
                raise ValidationError(None, {None: str(ex)})
203
204
205
206
        # 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
207
        pending_values = req.data.pop('pending_values')
208
        # handle all other remaining relations now
209
210
        while req.data['pending_others']:
            form_, field = req.data['pending_others'].pop()
211
212
213
214
215
216
217
218
219
220
            # attempt to retrieve values and original values if they have already gone through
            # handle_formfield (may not if there has been some not yet known eid at the first
            # processing round). In the later case we've to go back through handle_formfield.
            try:
                values, origvalues = pending_values.pop((form_, field))
            except KeyError:
                self.handle_formfield(form_, field)
            else:
                self.handle_relation(form_, field, values, origvalues)
        assert not pending_values, 'unexpected remaining pending values %s' % pending_values
221
        del req.data['pending_others']
222
        # then execute rql to set all relations
223
        for querydef in self.relations_rql:
224
            self._cw.execute(*querydef)
225
        # delete pending composite
226
227
        for entity in req.data['pending_composite_delete']:
            entity.cw_delete()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
228
        # XXX this processes *all* pending operations of *all* entities
229
        if '__delete' in req.form:
230
231
            todelete = req.list_form_param('__delete', req.form, pop=True)
            if todelete:
Sylvain Thénault's avatar
Sylvain Thénault committed
232
                autoform.delete_relations(self._cw, todelete)
233
        self._cw.remove_pending_operations()
234
        if self.errors:
Denis Laxalde's avatar
Denis Laxalde committed
235
            errors = dict((f.name, str(ex)) for f, ex in self.errors)
236
            raise ValidationError(valerror_eid(form.get('__maineid')), errors)
237

238
239
240
    def _insert_entity(self, etype, eid, rqlquery):
        rql = rqlquery.insert_query(etype)
        try:
241
            entity = self._cw.execute(rql, rqlquery.kwargs).get_entity(0, 0)
242
            neweid = entity.eid
243
        except ValidationError as ex:
244
            self._to_create[eid] = ex.entity
245
            if self._cw.ajax_request:  # XXX (syt) why?
246
247
248
249
250
251
                ex.entity = eid
            raise
        self._to_create[eid] = neweid
        return neweid

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

Adrien Di Mascio's avatar
Adrien Di Mascio committed
254
255
    def edit_entity(self, formparams, multiple=False):
        """edit / create / copy an entity and return its eid"""
256
        req = self._cw
Adrien Di Mascio's avatar
Adrien Di Mascio committed
257
        etype = formparams['__type']
258
        entity = req.vreg['etypes'].etype_class(etype)(req)
259
        entity.eid = valerror_eid(formparams['eid'])
260
        is_main_entity = req.form.get('__maineid') == formparams['eid']
261
262
263
264
265
266
267
        # 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
268
        if is_main_entity:
269
            formid = req.form.get('__form_id', 'edition')
270
271
272
273
        else:
            # XXX inlined forms formid should be saved in a different formparams entry
            # inbetween, use cubicweb standard formid for inlined forms
            formid = 'edition'
274
        form = req.vreg['forms'].select(formid, req, entity=entity)
275
        eid = form.actual_eid(entity.eid)
276
        editedfields = formparams['_cw_entity_fields']
277
        form.formvalues = {}  # init fields value cache
278
279
        for field in form.iter_modified_fields(editedfields, entity):
            self.handle_formfield(form, field, rqlquery)
280
281
282
283
        # 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)
284
285
        if not rqlquery.canceled:
            if self.errors:
Denis Laxalde's avatar
Denis Laxalde committed
286
                errors = dict((f.role_name(), str(ex)) for f, ex in self.errors)
287
                raise ValidationError(valerror_eid(entity.eid), errors)
288
            if eid is None:  # creation or copy
289
                entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
290
            elif rqlquery.edited:  # edition of an existant entity
Julien Cristau's avatar
Julien Cristau committed
291
                self.check_concurrent_edition(formparams, eid)
292
293
294
                self._update_entity(eid, rqlquery)
        else:
            self.errors = []
295
        if is_main_entity:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
296
            self.notify_edited(entity)
297
        if '__delete' in formparams:
298
            # XXX deprecate?
299
300
            todelete = req.list_form_param('__delete', formparams, pop=True)
            autoform.delete_relations(req, todelete)
301
        if '__cloned_eid' in formparams:
302
            entity.copy_relations(int(formparams['__cloned_eid']))
303
        if is_main_entity:  # only execute linkto for the main entity
Sandrine Ribeau's avatar
Sandrine Ribeau committed
304
            self.execute_linkto(entity.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
305
306
        return eid

307
    def handle_formfield(self, form, field, rqlquery=None):
308
309
        entity = form.edited_entity
        eschema = entity.e_schema
310
        try:
311
            for field, value in field.process_posted(form):
312
                if not ((field.role == 'subject' and field.name in eschema.subjrels)
313
                        or (field.role == 'object' and field.name in eschema.objrels)):
314
                    continue
315

Sandrine Ribeau's avatar
Sandrine Ribeau committed
316
                rschema = self._cw.vreg.schema.rschema(field.name)
Sandrine Ribeau's avatar
Sandrine Ribeau committed
317
                if rschema.final:
318
                    rqlquery.set_attribute(field.name, value)
319
320
321
                    continue

                if entity.has_eid():
322
                    origvalues = set(row[0] for row in entity.related(field.name, field.role).rows)
323
                else:
324
325
                    origvalues = set()
                if value is None or value == origvalues:
326
                    continue  # not edited / not modified / to do later
327

328
                unlinked_eids = origvalues - value
329

330
331
332
                if unlinked_eids:
                    # Special handling of composite relation removal
                    self.handle_composite_removal(
333
                        form, field, unlinked_eids, value, rqlquery)
334

335
336
337
338
                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)
339
                else:
340
                    form._cw.data['pending_others'].add((form, field))
341
                    form._cw.data['pending_values'][(form, field)] = (value, origvalues)
342

343
        except ProcessFormError as exc:
344
345
            self.errors.append((field, exc))

346
347
    def handle_composite_removal(self, form, field,
                                 removed_values, new_values, rqlquery):
348
349
350
351
352
353
354
355
356
        """
        In EditController-handled forms, when the user removes a composite
        relation, it triggers the removal of the related entity in the
        composite. This is where this happens.

        See for instance test_subject_subentity_removal in
        web/test/unittest_application.py.
        """
        rschema = self._cw.vreg.schema.rschema(field.name)
357
358
359
360
361
362
363
        new_value_etypes = set()
        # the user could have included nonexisting eids in the POST; don't crash.
        for eid in new_values:
            try:
                new_value_etypes.add(self._cw.entity_from_eid(eid).cw_etype)
            except UnknownEid:
                continue
364
365
366
367
368
369
370
371
372
        for unlinked_eid in removed_values:
            unlinked_entity = self._cw.entity_from_eid(unlinked_eid)
            rdef = rschema.role_rdef(form.edited_entity.cw_etype,
                                     unlinked_entity.cw_etype,
                                     field.role)
            if rdef.composite is not None:
                if rdef.composite == field.role:
                    to_be_removed = unlinked_entity
                else:
373
374
375
                    if unlinked_entity.cw_etype in new_value_etypes:
                        # This is a same-rdef re-parenting: do not remove the entity
                        continue
376
                    to_be_removed = form.edited_entity
377
378
379
                    self.info('Edition of %s is cancelled (deletion requested)',
                              to_be_removed)
                    rqlquery.canceled = True
380
381
                self.info('Scheduling removal of %s as composite relation '
                          '%s was removed', to_be_removed, rdef)
382
                form._cw.data['pending_composite_delete'].add(to_be_removed)
383

384
    def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
385
386
387
        """handle edition for the (rschema, x) relation of the given entity
        """
        if values:
388
            rqlquery.set_inlined(field.name, next(iter(values)))
389
390
        elif form.edited_entity.has_eid():
            self.handle_relation(form, field, values, origvalues)
391

392
    def handle_relation(self, form, field, values, origvalues):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
393
394
        """handle edition for the (rschema, x) relation of the given entity
        """
Sandrine Ribeau's avatar
Sandrine Ribeau committed
395
        rschema = self._cw.vreg.schema.rschema(field.name)
396
        if field.role == 'subject':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
397
398
399
            subjvar, objvar = 'X', 'Y'
        else:
            subjvar, objvar = 'Y', 'X'
400
        eid = form.edited_entity.eid
401
        if field.role == 'object' or not rschema.inlined or not values:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
402
403
            # this is not an inlined relation or no values specified,
            # explicty remove relations
404
405
            rql = 'DELETE %s %s %s WHERE X eid %%(x)s, Y eid %%(y)s' % (
                subjvar, rschema, objvar)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
406
            for reid in origvalues.difference(values):
407
                self.relations_rql.append((rql, {'x': eid, 'y': reid}))
408
409
410
411
412
        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:
413
                self.relations_rql.append((rql, {'x': eid, 'y': reid}))
414

415
416
417
418
419
420
    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)
421
            path, params = entity.cw_adapt_to('IEditControl').after_deletion_path()
422
            redirect_info.add((path, tuple(params.items())))
423
            entity.cw_delete()
424
425
426
427
        if len(redirect_info) > 1:
            # In the face of ambiguity, refuse the temptation to guess.
            self._after_deletion_path = 'view', ()
        else:
428
            self._after_deletion_path = next(iter(redirect_info))
429
430
431
432
433
        if len(eidtypes) > 1:
            self._cw.set_message(self._cw._('entities deleted'))
        else:
            self._cw.set_message(self._cw._('entity deleted'))

434
435
436
    def check_concurrent_edition(self, formparams, eid):
        req = self._cw
        try:
437
            form_ts = datetime.utcfromtimestamp(float(formparams['__form_generation_time']))
438
439
440
441
442
443
444
445
446
447
        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.
448
            raise ValidationError(eid, {None: msg}, {'eid': eid})
449

450
451
452
    def _action_apply(self):
        self._default_publish()
        self.reset()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
453

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