|
1 # copyright 2003-2014 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 """edit entity attributes/relations from any view, without going to the entity |
|
19 form |
|
20 """ |
|
21 |
|
22 __docformat__ = "restructuredtext en" |
|
23 from cubicweb import _ |
|
24 |
|
25 import copy |
|
26 from warnings import warn |
|
27 |
|
28 from logilab.mtconverter import xml_escape |
|
29 from logilab.common.deprecation import deprecated, class_renamed |
|
30 from logilab.common.decorators import cached |
|
31 |
|
32 from cubicweb import neg_role |
|
33 from cubicweb.schema import display_name |
|
34 from cubicweb.utils import json, json_dumps |
|
35 from cubicweb.predicates import non_final_entity, match_kwargs |
|
36 from cubicweb.view import EntityView |
|
37 from cubicweb.web import stdmsgs |
|
38 from cubicweb.web.views import uicfg |
|
39 from cubicweb.web.form import FieldNotFound |
|
40 from cubicweb.web.formwidgets import Button, SubmitButton |
|
41 from cubicweb.web.views.ajaxcontroller import ajaxfunc |
|
42 |
|
43 class _DummyForm(object): |
|
44 __slots__ = ('event_args',) |
|
45 def form_render(self, **_args): |
|
46 return u'' |
|
47 def render(self, *_args, **_kwargs): |
|
48 return u'' |
|
49 def append_field(self, *args): |
|
50 pass |
|
51 def add_hidden(self, *args): |
|
52 pass |
|
53 |
|
54 class AutoClickAndEditFormView(EntityView): |
|
55 __regid__ = 'reledit' |
|
56 __select__ = non_final_entity() & match_kwargs('rtype') |
|
57 |
|
58 # ui side continuations |
|
59 _onclick = (u"cw.reledit.loadInlineEditionForm('%(formid)s', %(eid)s, '%(rtype)s', '%(role)s', " |
|
60 "'%(divid)s', %(reload)s, '%(vid)s', '%(action)s');") |
|
61 _cancelclick = "cw.reledit.cleanupAfterCancel('%s')" |
|
62 |
|
63 # ui side actions/buttons |
|
64 _addzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>' |
|
65 _addmsg = _('click to add a value') |
|
66 _addlogo = 'plus.png' |
|
67 _deletezone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>' |
|
68 _deletemsg = _('click to delete this value') |
|
69 _deletelogo = 'cancel.png' |
|
70 _editzone = u'<img title="%(msg)s" src="%(logo)s" alt="%(msg)s"/>' |
|
71 _editzonemsg = _('click to edit this field') |
|
72 _editlogo = 'pen_icon.png' |
|
73 |
|
74 # renderer |
|
75 _form_renderer_id = 'base' |
|
76 |
|
77 def entity_call(self, entity, rtype=None, role='subject', |
|
78 reload=False, # controls reloading the whole page after change |
|
79 # boolean, eid (to redirect), or |
|
80 # function taking the subject entity & returning a boolean or an eid |
|
81 rvid=None, # vid to be applied to other side of rtype (non final relations only) |
|
82 default_value=None, |
|
83 formid='base', |
|
84 action=None |
|
85 ): |
|
86 """display field to edit entity's `rtype` relation on click""" |
|
87 assert rtype |
|
88 self._cw.add_css('cubicweb.form.css') |
|
89 self._cw.add_js(('cubicweb.reledit.js', 'cubicweb.edition.js', 'cubicweb.ajax.js')) |
|
90 self.entity = entity |
|
91 rschema = self._cw.vreg.schema[rtype] |
|
92 rctrl = self._cw.vreg['uicfg'].select('reledit', self._cw, entity=entity) |
|
93 self._rules = rctrl.etype_get(self.entity.e_schema.type, rschema.type, role, '*') |
|
94 reload = self._compute_reload(rschema, role, reload) |
|
95 divid = self._build_divid(rtype, role, self.entity.eid) |
|
96 if rschema.final: |
|
97 self._handle_attribute(rschema, role, divid, reload, action) |
|
98 else: |
|
99 if self._is_composite(): |
|
100 self._handle_composite(rschema, role, divid, reload, formid, action) |
|
101 else: |
|
102 self._handle_relation(rschema, role, divid, reload, formid, action) |
|
103 |
|
104 def _handle_attribute(self, rschema, role, divid, reload, action): |
|
105 rvid = self._rules.get('rvid', None) |
|
106 if rvid is not None: |
|
107 value = self._cw.view(rvid, entity=self.entity, |
|
108 rtype=rschema.type, role=role) |
|
109 else: |
|
110 value = self.entity.printable_value(rschema.type) |
|
111 if not self._should_edit_attribute(rschema): |
|
112 self.w(value) |
|
113 return |
|
114 form, renderer = self._build_form(self.entity, rschema, role, divid, |
|
115 'base', reload, action) |
|
116 value = value or self._compute_default_value(rschema, role) |
|
117 self.view_form(divid, value, form, renderer) |
|
118 |
|
119 def _compute_formid_value(self, rschema, role, rvid, formid): |
|
120 related_rset = self.entity.related(rschema.type, role) |
|
121 if related_rset: |
|
122 value = self._cw.view(rvid, related_rset) |
|
123 else: |
|
124 value = self._compute_default_value(rschema, role) |
|
125 if not self._should_edit_relation(rschema, role): |
|
126 return None, value |
|
127 return formid, value |
|
128 |
|
129 def _handle_relation(self, rschema, role, divid, reload, formid, action): |
|
130 rvid = self._rules.get('rvid', 'autolimited') |
|
131 formid, value = self._compute_formid_value(rschema, role, rvid, formid) |
|
132 if formid is None: |
|
133 return self.w(value) |
|
134 form, renderer = self._build_form(self.entity, rschema, role, divid, formid, |
|
135 reload, action, dict(vid=rvid)) |
|
136 self.view_form(divid, value, form, renderer) |
|
137 |
|
138 def _handle_composite(self, rschema, role, divid, reload, formid, action): |
|
139 # this is for attribute-like composites (1 target type, 1 related entity at most, for now) |
|
140 entity = self.entity |
|
141 related_rset = entity.related(rschema.type, role) |
|
142 add_related = self._may_add_related(related_rset, rschema, role) |
|
143 edit_related = self._may_edit_related_entity(related_rset, rschema, role) |
|
144 delete_related = edit_related and self._may_delete_related(related_rset, rschema, role) |
|
145 rvid = self._rules.get('rvid', 'autolimited') |
|
146 formid, value = self._compute_formid_value(rschema, role, rvid, formid) |
|
147 if formid is None or not (edit_related or add_related): |
|
148 # till we learn to handle cases where not (edit_related or add_related) |
|
149 self.w(value) |
|
150 return |
|
151 form, renderer = self._build_form(entity, rschema, role, divid, formid, |
|
152 reload, action, dict(vid=rvid)) |
|
153 self.view_form(divid, value, form, renderer, |
|
154 edit_related, add_related, delete_related) |
|
155 |
|
156 @cached |
|
157 def _compute_ttypes(self, rschema, role): |
|
158 dual_role = neg_role(role) |
|
159 return getattr(rschema, '%ss' % dual_role)() |
|
160 |
|
161 def _compute_reload(self, rschema, role, reload): |
|
162 ctrl_reload = self._rules.get('reload', reload) |
|
163 if callable(ctrl_reload): |
|
164 ctrl_reload = ctrl_reload(self.entity) |
|
165 if isinstance(ctrl_reload, int) and ctrl_reload > 1: # not True/False |
|
166 ctrl_reload = self._cw.build_url(ctrl_reload) |
|
167 return ctrl_reload |
|
168 |
|
169 def _compute_default_value(self, rschema, role): |
|
170 default = self._rules.get('novalue_label') |
|
171 if default is None: |
|
172 if self._rules.get('novalue_include_rtype'): |
|
173 default = self._cw._('<%s not specified>') % display_name( |
|
174 self._cw, rschema.type, role) |
|
175 else: |
|
176 default = self._cw._('<not specified>') |
|
177 else: |
|
178 default = self._cw._(default) |
|
179 return xml_escape(default) |
|
180 |
|
181 def _is_composite(self): |
|
182 return self._rules.get('edit_target') == 'related' |
|
183 |
|
184 def _may_add_related(self, related_rset, rschema, role): |
|
185 """ ok for attribute-like composite entities """ |
|
186 ttypes = self._compute_ttypes(rschema, role) |
|
187 if len(ttypes) > 1: # many etypes: learn how to do it |
|
188 return False |
|
189 rdef = rschema.role_rdef(self.entity.e_schema, ttypes[0], role) |
|
190 card = rdef.role_cardinality(role) |
|
191 if related_rset or card not in '?1': |
|
192 return False |
|
193 if role == 'subject': |
|
194 kwargs = {'fromeid': self.entity.eid} |
|
195 else: |
|
196 kwargs = {'toeid': self.entity.eid} |
|
197 return rdef.has_perm(self._cw, 'add', **kwargs) |
|
198 |
|
199 def _may_edit_related_entity(self, related_rset, rschema, role): |
|
200 """ controls the edition of the related entity """ |
|
201 ttypes = self._compute_ttypes(rschema, role) |
|
202 if len(ttypes) > 1 or len(related_rset.rows) != 1: |
|
203 return False |
|
204 if self.entity.e_schema.rdef(rschema, role).role_cardinality(role) not in '?1': |
|
205 return False |
|
206 return related_rset.get_entity(0, 0).cw_has_perm('update') |
|
207 |
|
208 def _may_delete_related(self, related_rset, rschema, role): |
|
209 # we assume may_edit_related, only 1 related entity |
|
210 if not related_rset: |
|
211 return False |
|
212 rentity = related_rset.get_entity(0, 0) |
|
213 entity = self.entity |
|
214 if role == 'subject': |
|
215 kwargs = {'fromeid': entity.eid, 'toeid': rentity.eid} |
|
216 cardinality = rschema.rdefs[(entity.cw_etype, rentity.cw_etype)].cardinality[0] |
|
217 else: |
|
218 kwargs = {'fromeid': rentity.eid, 'toeid': entity.eid} |
|
219 cardinality = rschema.rdefs[(rentity.cw_etype, entity.cw_etype)].cardinality[1] |
|
220 if cardinality in '1+': |
|
221 return False |
|
222 # NOTE: should be sufficient given a well built schema/security |
|
223 return rschema.has_perm(self._cw, 'delete', **kwargs) |
|
224 |
|
225 def _build_zone(self, zonedef, msg, logo): |
|
226 return zonedef % {'msg': xml_escape(self._cw._(msg)), |
|
227 'logo': xml_escape(self._cw.data_url(logo))} |
|
228 |
|
229 def _build_edit_zone(self): |
|
230 return self._build_zone(self._editzone, self._editzonemsg, self._editlogo) |
|
231 |
|
232 def _build_delete_zone(self): |
|
233 return self._build_zone(self._deletezone, self._deletemsg, self._deletelogo) |
|
234 |
|
235 def _build_add_zone(self): |
|
236 return self._build_zone(self._addzone, self._addmsg, self._addlogo) |
|
237 |
|
238 def _build_divid(self, rtype, role, entity_eid): |
|
239 """ builds an id for the root div of a reledit widget """ |
|
240 return '%s-%s-%s' % (rtype, role, entity_eid) |
|
241 |
|
242 def _build_args(self, entity, rtype, role, formid, reload, action, |
|
243 extradata=None): |
|
244 divid = self._build_divid(rtype, role, entity.eid) |
|
245 event_args = {'divid' : divid, 'eid' : entity.eid, 'rtype' : rtype, 'formid': formid, |
|
246 'reload' : json_dumps(reload), 'action': action, |
|
247 'role' : role, 'vid' : u''} |
|
248 if extradata: |
|
249 event_args.update(extradata) |
|
250 return event_args |
|
251 |
|
252 def _prepare_form(self, entity, rschema, role, action): |
|
253 assert action in ('edit_rtype', 'edit_related', 'add', 'delete'), action |
|
254 if action == 'edit_rtype': |
|
255 return False, entity |
|
256 label = True |
|
257 if action in ('edit_related', 'delete'): |
|
258 edit_entity = entity.related(rschema, role).get_entity(0, 0) |
|
259 elif action == 'add': |
|
260 add_etype = self._compute_ttypes(rschema, role)[0] |
|
261 _new_entity = self._cw.vreg['etypes'].etype_class(add_etype)(self._cw) |
|
262 _new_entity.eid = next(self._cw.varmaker) |
|
263 edit_entity = _new_entity |
|
264 # XXX see forms.py ~ 276 and entities.linked_to method |
|
265 # is there another way? |
|
266 self._cw.form['__linkto'] = '%s:%s:%s' % (rschema, entity.eid, neg_role(role)) |
|
267 assert edit_entity |
|
268 return label, edit_entity |
|
269 |
|
270 def _build_renderer(self, related_entity, display_label): |
|
271 return self._cw.vreg['formrenderers'].select( |
|
272 self._form_renderer_id, self._cw, entity=related_entity, |
|
273 display_label=display_label, |
|
274 table_class='attributeForm' if display_label else '', |
|
275 display_help=False, button_bar_class='buttonbar', |
|
276 display_progress_div=False) |
|
277 |
|
278 def _build_form(self, entity, rschema, role, divid, formid, reload, action, |
|
279 extradata=None, **formargs): |
|
280 rtype = rschema.type |
|
281 event_args = self._build_args(entity, rtype, role, formid, reload, action, extradata) |
|
282 if not action: |
|
283 form = _DummyForm() |
|
284 form.event_args = event_args |
|
285 return form, None |
|
286 label, edit_entity = self._prepare_form(entity, rschema, role, action) |
|
287 cancelclick = self._cancelclick % divid |
|
288 form = self._cw.vreg['forms'].select( |
|
289 formid, self._cw, rset=edit_entity.as_rset(), entity=edit_entity, |
|
290 domid='%s-form' % divid, formtype='inlined', |
|
291 action=self._cw.build_url('validateform', __onsuccess='window.parent.cw.reledit.onSuccess'), |
|
292 cwtarget='eformframe', cssclass='releditForm', |
|
293 **formargs) |
|
294 # pass reledit arguments |
|
295 for pname, pvalue in event_args.items(): |
|
296 form.add_hidden('__reledit|' + pname, pvalue) |
|
297 # handle buttons |
|
298 if form.form_buttons: # edition, delete |
|
299 form_buttons = [] |
|
300 for button in form.form_buttons: |
|
301 if not button.label.endswith('apply'): |
|
302 if button.label.endswith('cancel'): |
|
303 button = copy.deepcopy(button) |
|
304 button.cwaction = None |
|
305 button.onclick = cancelclick |
|
306 form_buttons.append(button) |
|
307 form.form_buttons = form_buttons |
|
308 else: # base |
|
309 form.form_buttons = [SubmitButton(), |
|
310 Button(stdmsgs.BUTTON_CANCEL, onclick=cancelclick)] |
|
311 form.event_args = event_args |
|
312 if formid == 'base': |
|
313 field = form.field_by_name(rtype, role, entity.e_schema) |
|
314 form.append_field(field) |
|
315 return form, self._build_renderer(edit_entity, label) |
|
316 |
|
317 def _should_edit_attribute(self, rschema): |
|
318 entity = self.entity |
|
319 rdef = entity.e_schema.rdef(rschema) |
|
320 # check permissions |
|
321 if not entity.cw_has_perm('update'): |
|
322 return False |
|
323 rdef = entity.e_schema.rdef(rschema) |
|
324 return rdef.has_perm(self._cw, 'update', eid=entity.eid) |
|
325 |
|
326 def _should_edit_relation(self, rschema, role): |
|
327 eeid = self.entity.eid |
|
328 perm_args = {'fromeid': eeid} if role == 'subject' else {'toeid': eeid} |
|
329 return rschema.has_perm(self._cw, 'add', **perm_args) |
|
330 |
|
331 def _open_form_wrapper(self, divid, value, form, renderer, |
|
332 _edit_related, _add_related, _delete_related): |
|
333 w = self.w |
|
334 w(u'<div id="%(id)s-reledit" onmouseout="%(out)s" onmouseover="%(over)s" class="%(css)s">' % |
|
335 {'id': divid, 'css': 'releditField', |
|
336 'out': "jQuery('#%s').addClass('invisible')" % divid, |
|
337 'over': "jQuery('#%s').removeClass('invisible')" % divid}) |
|
338 w(u'<div id="%s-value" class="editableFieldValue">' % divid) |
|
339 w(value) |
|
340 w(u'</div>') |
|
341 form.render(w=w, renderer=renderer) |
|
342 w(u'<div id="%s" class="editableField invisible">' % divid) |
|
343 |
|
344 def _edit_action(self, divid, args, edit_related, add_related, _delete_related): |
|
345 # XXX disambiguate wrt edit_related |
|
346 if not add_related: # currently, excludes edition |
|
347 w = self.w |
|
348 args['formid'] = 'edition' if edit_related else 'base' |
|
349 args['action'] = 'edit_related' if edit_related else 'edit_rtype' |
|
350 w(u'<div id="%s-update" class="editableField" onclick="%s" title="%s">' % |
|
351 (divid, xml_escape(self._onclick % args), self._cw._(self._editzonemsg))) |
|
352 w(self._build_edit_zone()) |
|
353 w(u'</div>') |
|
354 |
|
355 def _add_action(self, divid, args, _edit_related, add_related, _delete_related): |
|
356 if add_related: |
|
357 w = self.w |
|
358 args['formid'] = 'edition' |
|
359 args['action'] = 'add' |
|
360 w(u'<div id="%s-add" class="editableField" onclick="%s" title="%s">' % |
|
361 (divid, xml_escape(self._onclick % args), self._cw._(self._addmsg))) |
|
362 w(self._build_add_zone()) |
|
363 w(u'</div>') |
|
364 |
|
365 def _del_action(self, divid, args, _edit_related, _add_related, delete_related): |
|
366 if delete_related: |
|
367 w = self.w |
|
368 args['formid'] = 'deleteconf' |
|
369 args['action'] = 'delete' |
|
370 w(u'<div id="%s-delete" class="editableField" onclick="%s" title="%s">' % |
|
371 (divid, xml_escape(self._onclick % args), self._cw._(self._deletemsg))) |
|
372 w(self._build_delete_zone()) |
|
373 w(u'</div>') |
|
374 |
|
375 def _close_form_wrapper(self): |
|
376 self.w(u'</div>') |
|
377 self.w(u'</div>') |
|
378 |
|
379 def view_form(self, divid, value, form=None, renderer=None, |
|
380 edit_related=False, add_related=False, delete_related=False): |
|
381 self._open_form_wrapper(divid, value, form, renderer, |
|
382 edit_related, add_related, delete_related) |
|
383 args = form.event_args.copy() |
|
384 self._edit_action(divid, args, edit_related, add_related, delete_related) |
|
385 self._add_action(divid, args, edit_related, add_related, delete_related) |
|
386 self._del_action(divid, args, edit_related, add_related, delete_related) |
|
387 self._close_form_wrapper() |
|
388 |
|
389 |
|
390 ClickAndEditFormView = class_renamed('ClickAndEditFormView', AutoClickAndEditFormView) |
|
391 |
|
392 |
|
393 @ajaxfunc(output_type='xhtml') |
|
394 def reledit_form(self): |
|
395 req = self._cw |
|
396 args = dict((x, req.form[x]) |
|
397 for x in ('formid', 'rtype', 'role', 'reload', 'action')) |
|
398 rset = req.eid_rset(int(self._cw.form['eid'])) |
|
399 try: |
|
400 args['reload'] = json.loads(args['reload']) |
|
401 except ValueError: # not true/false, an absolute url |
|
402 assert args['reload'].startswith('http') |
|
403 view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype']) |
|
404 return self._call_view(view, **args) |