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() |
|