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 """workflow views: |
|
19 |
|
20 * IWorkflowable views and forms |
|
21 * workflow entities views (State, Transition, TrInfo) |
|
22 """ |
|
23 |
|
24 __docformat__ = "restructuredtext en" |
|
25 from cubicweb import _ |
|
26 |
|
27 import os |
|
28 from warnings import warn |
|
29 |
|
30 from six import add_metaclass |
|
31 |
|
32 from logilab.mtconverter import xml_escape |
|
33 from logilab.common.graph import escape |
|
34 from logilab.common.deprecation import class_deprecated |
|
35 |
|
36 from cubicweb import Unauthorized |
|
37 from cubicweb.predicates import (has_related_entities, one_line_rset, |
|
38 relation_possible, match_form_params, |
|
39 score_entity, is_instance, adaptable) |
|
40 from cubicweb.view import EntityView |
|
41 from cubicweb.schema import display_name |
|
42 from cubicweb.web import stdmsgs, action, component, form, action |
|
43 from cubicweb.web import formfields as ff, formwidgets as fwdgs |
|
44 from cubicweb.web.views import TmpFileViewMixin |
|
45 from cubicweb.web.views import uicfg, forms, primary, ibreadcrumbs |
|
46 from cubicweb.web.views.tabs import TabbedPrimaryView, PrimaryTab |
|
47 from cubicweb.web.views.dotgraphview import DotGraphView, DotPropsHandler |
|
48 |
|
49 _pvs = uicfg.primaryview_section |
|
50 _pvs.tag_subject_of(('Workflow', 'initial_state', '*'), 'hidden') |
|
51 _pvs.tag_object_of(('*', 'state_of', 'Workflow'), 'hidden') |
|
52 _pvs.tag_object_of(('*', 'transition_of', 'Workflow'), 'hidden') |
|
53 _pvs.tag_object_of(('*', 'wf_info_for', '*'), 'hidden') |
|
54 for rtype in ('in_state', 'by_transition', 'from_state', 'to_state'): |
|
55 _pvs.tag_subject_of(('*', rtype, '*'), 'hidden') |
|
56 _pvs.tag_object_of(('*', rtype, '*'), 'hidden') |
|
57 _pvs.tag_object_of(('*', 'wf_info_for', '*'), 'hidden') |
|
58 |
|
59 _abaa = uicfg.actionbox_appearsin_addmenu |
|
60 _abaa.tag_subject_of(('BaseTransition', 'condition', 'RQLExpression'), False) |
|
61 _abaa.tag_subject_of(('State', 'allowed_transition', 'BaseTransition'), False) |
|
62 _abaa.tag_object_of(('SubWorkflowExitPoint', 'destination_state', 'State'), |
|
63 False) |
|
64 _abaa.tag_subject_of(('*', 'wf_info_for', '*'), False) |
|
65 _abaa.tag_object_of(('*', 'wf_info_for', '*'), False) |
|
66 |
|
67 _abaa.tag_object_of(('*', 'state_of', 'CWEType'), True) |
|
68 _abaa.tag_object_of(('*', 'transition_of', 'CWEType'), True) |
|
69 _abaa.tag_subject_of(('Transition', 'destination_state', '*'), True) |
|
70 _abaa.tag_object_of(('*', 'allowed_transition', 'Transition'), True) |
|
71 _abaa.tag_object_of(('*', 'destination_state', 'State'), True) |
|
72 _abaa.tag_subject_of(('State', 'allowed_transition', '*'), True) |
|
73 _abaa.tag_object_of(('State', 'state_of', 'Workflow'), True) |
|
74 _abaa.tag_object_of(('Transition', 'transition_of', 'Workflow'), True) |
|
75 _abaa.tag_object_of(('WorkflowTransition', 'transition_of', 'Workflow'), True) |
|
76 |
|
77 _afs = uicfg.autoform_section |
|
78 _affk = uicfg.autoform_field_kwargs |
|
79 |
|
80 # IWorkflowable views ######################################################### |
|
81 |
|
82 class ChangeStateForm(forms.CompositeEntityForm): |
|
83 # set dom id to ensure there is no conflict with edition form (see |
|
84 # session_key() implementation) |
|
85 __regid__ = domid = 'changestate' |
|
86 |
|
87 form_renderer_id = 'base' # don't want EntityFormRenderer |
|
88 form_buttons = [fwdgs.SubmitButton(), |
|
89 fwdgs.Button(stdmsgs.BUTTON_CANCEL, cwaction='cancel')] |
|
90 |
|
91 |
|
92 class ChangeStateFormView(form.FormViewMixIn, EntityView): |
|
93 __regid__ = 'statuschange' |
|
94 title = _('status change') |
|
95 __select__ = (one_line_rset() |
|
96 & match_form_params('treid') |
|
97 & adaptable('IWorkflowable')) |
|
98 |
|
99 def cell_call(self, row, col): |
|
100 entity = self.cw_rset.get_entity(row, col) |
|
101 transition = self._cw.entity_from_eid(self._cw.form['treid']) |
|
102 form = self.get_form(entity, transition) |
|
103 self.w(u'<h4>%s %s</h4>\n' % (self._cw._(transition.name), |
|
104 entity.view('oneline'))) |
|
105 msg = self._cw._('status will change from %(st1)s to %(st2)s') % { |
|
106 'st1': entity.cw_adapt_to('IWorkflowable').printable_state, |
|
107 'st2': self._cw._(transition.destination(entity).name)} |
|
108 self.w(u'<p>%s</p>\n' % msg) |
|
109 form.render(w=self.w) |
|
110 |
|
111 def redirectpath(self, entity): |
|
112 return entity.rest_path() |
|
113 |
|
114 def get_form(self, entity, transition, **kwargs): |
|
115 # XXX used to specify both rset/row/col and entity in case implements |
|
116 # selector (and not is_instance) is used on custom form |
|
117 form = self._cw.vreg['forms'].select( |
|
118 'changestate', self._cw, entity=entity, transition=transition, |
|
119 redirect_path=self.redirectpath(entity), **kwargs) |
|
120 trinfo = self._cw.vreg['etypes'].etype_class('TrInfo')(self._cw) |
|
121 trinfo.eid = next(self._cw.varmaker) |
|
122 subform = self._cw.vreg['forms'].select('edition', self._cw, entity=trinfo, |
|
123 mainform=False) |
|
124 subform.field_by_name('wf_info_for', 'subject').value = entity.eid |
|
125 trfield = subform.field_by_name('by_transition', 'subject') |
|
126 trfield.widget = fwdgs.HiddenInput() |
|
127 trfield.value = transition.eid |
|
128 form.add_subform(subform) |
|
129 return form |
|
130 |
|
131 |
|
132 class WFHistoryView(EntityView): |
|
133 __regid__ = 'wfhistory' |
|
134 __select__ = relation_possible('wf_info_for', role='object') & \ |
|
135 score_entity(lambda x: x.cw_adapt_to('IWorkflowable').workflow_history) |
|
136 |
|
137 title = _('Workflow history') |
|
138 |
|
139 def cell_call(self, row, col, view=None, title=title): |
|
140 _ = self._cw._ |
|
141 eid = self.cw_rset[row][col] |
|
142 sel = 'Any FS,TS,C,D' |
|
143 rql = ' ORDERBY D DESC WHERE WF wf_info_for X,'\ |
|
144 'WF from_state FS, WF to_state TS, WF comment C,'\ |
|
145 'WF creation_date D' |
|
146 if self._cw.vreg.schema.eschema('CWUser').has_perm(self._cw, 'read'): |
|
147 sel += ',U,WF' |
|
148 rql += ', WF owned_by U?' |
|
149 headers = (_('from_state'), _('to_state'), _('comment'), _('date'), |
|
150 _('CWUser')) |
|
151 else: |
|
152 sel += ',WF' |
|
153 headers = (_('from_state'), _('to_state'), _('comment'), _('date')) |
|
154 rql = '%s %s, X eid %%(x)s' % (sel, rql) |
|
155 try: |
|
156 rset = self._cw.execute(rql, {'x': eid}) |
|
157 except Unauthorized: |
|
158 return |
|
159 if rset: |
|
160 if title: |
|
161 self.w(u'<h2>%s</h2>\n' % _(title)) |
|
162 self.wview('table', rset, headers=headers, |
|
163 cellvids={2: 'editable-final'}) |
|
164 |
|
165 |
|
166 class WFHistoryVComponent(component.EntityCtxComponent): |
|
167 """display the workflow history for entities supporting it""" |
|
168 __regid__ = 'wfhistory' |
|
169 __select__ = component.EntityCtxComponent.__select__ & WFHistoryView.__select__ |
|
170 context = 'navcontentbottom' |
|
171 title = _('Workflow history') |
|
172 |
|
173 def render_body(self, w): |
|
174 self.entity.view('wfhistory', w=w, title=None) |
|
175 |
|
176 |
|
177 class InContextWithStateView(EntityView): |
|
178 """display incontext view for an entity as well as its current state""" |
|
179 __regid__ = 'incontext-state' |
|
180 __select__ = adaptable('IWorkflowable') |
|
181 def entity_call(self, entity): |
|
182 iwf = entity.cw_adapt_to('IWorkflowable') |
|
183 self.w(u'%s [%s]' % (entity.view('incontext'), iwf.printable_state)) |
|
184 |
|
185 |
|
186 # workflow actions ############################################################# |
|
187 |
|
188 class WorkflowActions(action.Action): |
|
189 """fill 'workflow' sub-menu of the actions box""" |
|
190 __regid__ = 'workflow' |
|
191 __select__ = (action.Action.__select__ & one_line_rset() & |
|
192 relation_possible('in_state')) |
|
193 |
|
194 submenu = _('workflow') |
|
195 order = 10 |
|
196 |
|
197 def fill_menu(self, box, menu): |
|
198 entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0) |
|
199 menu.label = u'%s: %s' % (self._cw._('state'), |
|
200 entity.cw_adapt_to('IWorkflowable').printable_state) |
|
201 menu.append_anyway = True |
|
202 super(WorkflowActions, self).fill_menu(box, menu) |
|
203 |
|
204 def actual_actions(self): |
|
205 entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0) |
|
206 iworkflowable = entity.cw_adapt_to('IWorkflowable') |
|
207 hastr = False |
|
208 for tr in iworkflowable.possible_transitions(): |
|
209 url = entity.absolute_url(vid='statuschange', treid=tr.eid) |
|
210 yield self.build_action(self._cw._(tr.name), url) |
|
211 hastr = True |
|
212 # don't propose to see wf if user can't pass any transition |
|
213 if hastr: |
|
214 wfurl = iworkflowable.current_workflow.absolute_url() |
|
215 yield self.build_action(self._cw._('view workflow'), wfurl) |
|
216 if iworkflowable.workflow_history: |
|
217 wfurl = entity.absolute_url(vid='wfhistory') |
|
218 yield self.build_action(self._cw._('view history'), wfurl) |
|
219 |
|
220 |
|
221 # workflow entity types views ################################################## |
|
222 |
|
223 _pvs = uicfg.primaryview_section |
|
224 _pvs.tag_subject_of(('Workflow', 'initial_state', '*'), 'hidden') |
|
225 _pvs.tag_object_of(('*', 'state_of', 'Workflow'), 'hidden') |
|
226 _pvs.tag_object_of(('*', 'transition_of', 'Workflow'), 'hidden') |
|
227 _pvs.tag_object_of(('*', 'default_workflow', 'Workflow'), 'hidden') |
|
228 |
|
229 _abaa = uicfg.actionbox_appearsin_addmenu |
|
230 _abaa.tag_subject_of(('BaseTransition', 'condition', 'RQLExpression'), False) |
|
231 _abaa.tag_subject_of(('State', 'allowed_transition', 'BaseTransition'), False) |
|
232 _abaa.tag_object_of(('SubWorkflowExitPoint', 'destination_state', 'State'), |
|
233 False) |
|
234 _abaa.tag_object_of(('State', 'state_of', 'Workflow'), True) |
|
235 _abaa.tag_object_of(('BaseTransition', 'transition_of', 'Workflow'), False) |
|
236 _abaa.tag_object_of(('Transition', 'transition_of', 'Workflow'), True) |
|
237 _abaa.tag_object_of(('WorkflowTransition', 'transition_of', 'Workflow'), True) |
|
238 |
|
239 class WorkflowPrimaryView(TabbedPrimaryView): |
|
240 __select__ = is_instance('Workflow') |
|
241 tabs = [ _('wf_tab_info'), _('wfgraph'),] |
|
242 default_tab = 'wf_tab_info' |
|
243 |
|
244 |
|
245 class StateInContextView(EntityView): |
|
246 """convenience trick, State's incontext view should not be clickable""" |
|
247 __regid__ = 'incontext' |
|
248 __select__ = is_instance('State') |
|
249 |
|
250 def cell_call(self, row, col): |
|
251 self.w(xml_escape(self._cw.view('textincontext', self.cw_rset, |
|
252 row=row, col=col))) |
|
253 |
|
254 class WorkflowTabTextView(PrimaryTab): |
|
255 __regid__ = 'wf_tab_info' |
|
256 __select__ = PrimaryTab.__select__ & one_line_rset() & is_instance('Workflow') |
|
257 |
|
258 def render_entity_attributes(self, entity): |
|
259 _ = self._cw._ |
|
260 self.w(u'<div>%s</div>' % (entity.printable_value('description'))) |
|
261 self.w(u'<span>%s%s</span>' % (_("workflow_of").capitalize(), _(" :"))) |
|
262 html = [] |
|
263 for e in entity.workflow_of: |
|
264 view = e.view('outofcontext') |
|
265 if entity.eid == e.default_workflow[0].eid: |
|
266 view += u' <span>[%s]</span>' % _('default_workflow') |
|
267 html.append(view) |
|
268 self.w(', '.join(v for v in html)) |
|
269 self.w(u'<h2>%s</h2>' % _("Transition_plural")) |
|
270 rset = self._cw.execute( |
|
271 'Any T,T,DS,T,TT ORDERBY TN WHERE T transition_of WF, WF eid %(x)s,' |
|
272 'T type TT, T name TN, T destination_state DS?', {'x': entity.eid}) |
|
273 self.wview('table', rset, 'null', |
|
274 cellvids={ 1: 'trfromstates', 2: 'outofcontext', 3:'trsecurity',}, |
|
275 headers = (_('Transition'), _('from_state'), |
|
276 _('to_state'), _('permissions'), _('type') ), |
|
277 ) |
|
278 |
|
279 |
|
280 class TransitionSecurityTextView(EntityView): |
|
281 __regid__ = 'trsecurity' |
|
282 __select__ = is_instance('Transition') |
|
283 |
|
284 def cell_call(self, row, col): |
|
285 _ = self._cw._ |
|
286 entity = self.cw_rset.get_entity(self.cw_row, self.cw_col) |
|
287 if entity.require_group: |
|
288 self.w(u'<div>%s%s %s</div>' % |
|
289 (_('groups'), _(" :"), |
|
290 u', '.join((g.view('incontext') for g |
|
291 in entity.require_group)))) |
|
292 if entity.condition: |
|
293 self.w(u'<div>%s%s %s</div>' % |
|
294 ( _('conditions'), _(" :"), |
|
295 u'<br/>'.join((e.dc_title() for e |
|
296 in entity.condition)))) |
|
297 |
|
298 class TransitionAllowedTextView(EntityView): |
|
299 __regid__ = 'trfromstates' |
|
300 __select__ = is_instance('Transition') |
|
301 |
|
302 def cell_call(self, row, col): |
|
303 entity = self.cw_rset.get_entity(self.cw_row, self.cw_col) |
|
304 self.w(u', '.join((e.view('outofcontext') for e |
|
305 in entity.reverse_allowed_transition))) |
|
306 |
|
307 |
|
308 # workflow entity types edition ################################################ |
|
309 |
|
310 def _wf_items_for_relation(req, wfeid, wfrelation, field): |
|
311 wf = req.entity_from_eid(wfeid) |
|
312 rschema = req.vreg.schema[field.name] |
|
313 param = 'toeid' if field.role == 'subject' else 'fromeid' |
|
314 return sorted((e.view('combobox'), unicode(e.eid)) |
|
315 for e in getattr(wf, 'reverse_%s' % wfrelation) |
|
316 if rschema.has_perm(req, 'add', **{param: e.eid})) |
|
317 |
|
318 # TrInfo |
|
319 _afs.tag_subject_of(('TrInfo', 'to_state', '*'), 'main', 'hidden') |
|
320 _afs.tag_subject_of(('TrInfo', 'from_state', '*'), 'main', 'hidden') |
|
321 _afs.tag_attribute(('TrInfo', 'tr_count'), 'main', 'hidden') |
|
322 |
|
323 # BaseTransition |
|
324 # XXX * allowed_transition BaseTransition |
|
325 # XXX BaseTransition destination_state * |
|
326 |
|
327 def transition_states_vocabulary(form, field): |
|
328 entity = form.edited_entity |
|
329 if entity.has_eid(): |
|
330 wfeid = entity.transition_of[0].eid |
|
331 else: |
|
332 eids = form.linked_to.get(('transition_of', 'subject')) |
|
333 if not eids: |
|
334 return [] |
|
335 wfeid = eids[0] |
|
336 return _wf_items_for_relation(form._cw, wfeid, 'state_of', field) |
|
337 |
|
338 _afs.tag_subject_of(('*', 'destination_state', '*'), 'main', 'attributes') |
|
339 _affk.tag_subject_of(('*', 'destination_state', '*'), |
|
340 {'choices': transition_states_vocabulary}) |
|
341 _afs.tag_object_of(('*', 'allowed_transition', '*'), 'main', 'attributes') |
|
342 _affk.tag_object_of(('*', 'allowed_transition', '*'), |
|
343 {'choices': transition_states_vocabulary}) |
|
344 |
|
345 # State |
|
346 |
|
347 def state_transitions_vocabulary(form, field): |
|
348 entity = form.edited_entity |
|
349 if entity.has_eid(): |
|
350 wfeid = entity.state_of[0].eid |
|
351 else : |
|
352 eids = form.linked_to.get(('state_of', 'subject')) |
|
353 if not eids: |
|
354 return [] |
|
355 wfeid = eids[0] |
|
356 return _wf_items_for_relation(form._cw, wfeid, 'transition_of', field) |
|
357 |
|
358 _afs.tag_subject_of(('State', 'allowed_transition', '*'), 'main', 'attributes') |
|
359 _affk.tag_subject_of(('State', 'allowed_transition', '*'), |
|
360 {'choices': state_transitions_vocabulary}) |
|
361 |
|
362 |
|
363 # adaptaters ################################################################### |
|
364 |
|
365 class WorkflowIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter): |
|
366 __select__ = is_instance('Workflow') |
|
367 # XXX what if workflow of multiple types? |
|
368 def parent_entity(self): |
|
369 return self.entity.workflow_of and self.entity.workflow_of[0] or None |
|
370 |
|
371 class WorkflowItemIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter): |
|
372 __select__ = is_instance('BaseTransition', 'State') |
|
373 def parent_entity(self): |
|
374 return self.entity.workflow |
|
375 |
|
376 class TransitionItemIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter): |
|
377 __select__ = is_instance('SubWorkflowExitPoint') |
|
378 def parent_entity(self): |
|
379 return self.entity.reverse_subworkflow_exit[0] |
|
380 |
|
381 class TrInfoIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter): |
|
382 __select__ = is_instance('TrInfo') |
|
383 def parent_entity(self): |
|
384 return self.entity.for_entity |
|
385 |
|
386 |
|
387 # workflow images ############################################################## |
|
388 |
|
389 class WorkflowDotPropsHandler(DotPropsHandler): |
|
390 |
|
391 def node_properties(self, stateortransition): |
|
392 """return default DOT drawing options for a state or transition""" |
|
393 props = super(WorkflowDotPropsHandler, self).node_properties(stateortransition) |
|
394 if hasattr(stateortransition, 'state_of'): |
|
395 props['shape'] = 'box' |
|
396 props['style'] = 'filled' |
|
397 if stateortransition.reverse_initial_state: |
|
398 props['fillcolor'] = '#88CC88' |
|
399 else: |
|
400 props['shape'] = 'ellipse' |
|
401 return props |
|
402 |
|
403 |
|
404 class WorkflowVisitor(object): |
|
405 def __init__(self, entity): |
|
406 self.entity = entity |
|
407 |
|
408 def nodes(self): |
|
409 for state in self.entity.reverse_state_of: |
|
410 state.complete() |
|
411 yield state.eid, state |
|
412 for transition in self.entity.reverse_transition_of: |
|
413 transition.complete() |
|
414 yield transition.eid, transition |
|
415 |
|
416 def edges(self): |
|
417 for transition in self.entity.reverse_transition_of: |
|
418 for incomingstate in transition.reverse_allowed_transition: |
|
419 yield incomingstate.eid, transition.eid, transition |
|
420 for outgoingstate in transition.potential_destinations(): |
|
421 yield transition.eid, outgoingstate.eid, transition |
|
422 |
|
423 class WorkflowGraphView(DotGraphView): |
|
424 __regid__ = 'wfgraph' |
|
425 __select__ = EntityView.__select__ & one_line_rset() & is_instance('Workflow') |
|
426 |
|
427 def build_visitor(self, entity): |
|
428 return WorkflowVisitor(entity) |
|
429 |
|
430 def build_dotpropshandler(self): |
|
431 return WorkflowDotPropsHandler(self._cw) |
|
432 |
|
433 |
|
434 @add_metaclass(class_deprecated) |
|
435 class TmpPngView(TmpFileViewMixin, EntityView): |
|
436 __deprecation_warning__ = '[3.18] %(cls)s is deprecated' |
|
437 __regid__ = 'tmppng' |
|
438 __select__ = match_form_params('tmpfile') |
|
439 content_type = 'image/png' |
|
440 binary = True |
|
441 |
|
442 def cell_call(self, row=0, col=0): |
|
443 key = self._cw.form['tmpfile'] |
|
444 if key not in self._cw.session.data: |
|
445 # the temp file is gone and there's nothing |
|
446 # we can do about it |
|
447 # we should probably write it to some well |
|
448 # behaved place and serve it |
|
449 return |
|
450 tmpfile = self._cw.session.data.pop(key) |
|
451 self.w(open(tmpfile, 'rb').read()) |
|
452 os.unlink(tmpfile) |
|