web/views/workflow.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     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)