cubicweb/web/views/workflow.py
changeset 11057 0b59724cb3f2
parent 10688 fa29f3628a1b
child 11197 9f1c89e7426d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/views/workflow.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,452 @@
+# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# 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.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# 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/>.
+"""workflow views:
+
+* IWorkflowable views and forms
+* workflow entities views (State, Transition, TrInfo)
+"""
+
+__docformat__ = "restructuredtext en"
+from cubicweb import _
+
+import os
+from warnings import warn
+
+from six import add_metaclass
+
+from logilab.mtconverter import xml_escape
+from logilab.common.graph import escape
+from logilab.common.deprecation import class_deprecated
+
+from cubicweb import Unauthorized
+from cubicweb.predicates import (has_related_entities, one_line_rset,
+                                relation_possible, match_form_params,
+                                score_entity, is_instance, adaptable)
+from cubicweb.view import EntityView
+from cubicweb.schema import display_name
+from cubicweb.web import stdmsgs, action, component, form, action
+from cubicweb.web import formfields as ff, formwidgets as fwdgs
+from cubicweb.web.views import TmpFileViewMixin
+from cubicweb.web.views import uicfg, forms, primary, ibreadcrumbs
+from cubicweb.web.views.tabs import TabbedPrimaryView, PrimaryTab
+from cubicweb.web.views.dotgraphview import DotGraphView, DotPropsHandler
+
+_pvs = uicfg.primaryview_section
+_pvs.tag_subject_of(('Workflow', 'initial_state', '*'), 'hidden')
+_pvs.tag_object_of(('*', 'state_of', 'Workflow'), 'hidden')
+_pvs.tag_object_of(('*', 'transition_of', 'Workflow'), 'hidden')
+_pvs.tag_object_of(('*', 'wf_info_for', '*'), 'hidden')
+for rtype in ('in_state', 'by_transition', 'from_state', 'to_state'):
+    _pvs.tag_subject_of(('*', rtype, '*'), 'hidden')
+    _pvs.tag_object_of(('*', rtype, '*'), 'hidden')
+_pvs.tag_object_of(('*', 'wf_info_for', '*'), 'hidden')
+
+_abaa = uicfg.actionbox_appearsin_addmenu
+_abaa.tag_subject_of(('BaseTransition', 'condition', 'RQLExpression'), False)
+_abaa.tag_subject_of(('State', 'allowed_transition', 'BaseTransition'), False)
+_abaa.tag_object_of(('SubWorkflowExitPoint', 'destination_state', 'State'),
+                    False)
+_abaa.tag_subject_of(('*', 'wf_info_for', '*'), False)
+_abaa.tag_object_of(('*', 'wf_info_for', '*'), False)
+
+_abaa.tag_object_of(('*', 'state_of', 'CWEType'), True)
+_abaa.tag_object_of(('*', 'transition_of', 'CWEType'), True)
+_abaa.tag_subject_of(('Transition', 'destination_state', '*'), True)
+_abaa.tag_object_of(('*', 'allowed_transition', 'Transition'), True)
+_abaa.tag_object_of(('*', 'destination_state', 'State'), True)
+_abaa.tag_subject_of(('State', 'allowed_transition', '*'), True)
+_abaa.tag_object_of(('State', 'state_of', 'Workflow'), True)
+_abaa.tag_object_of(('Transition', 'transition_of', 'Workflow'), True)
+_abaa.tag_object_of(('WorkflowTransition', 'transition_of', 'Workflow'), True)
+
+_afs = uicfg.autoform_section
+_affk = uicfg.autoform_field_kwargs
+
+# IWorkflowable views #########################################################
+
+class ChangeStateForm(forms.CompositeEntityForm):
+    # set dom id to ensure there is no conflict with edition form (see
+    # session_key() implementation)
+    __regid__ = domid = 'changestate'
+
+    form_renderer_id = 'base' # don't want EntityFormRenderer
+    form_buttons = [fwdgs.SubmitButton(),
+                    fwdgs.Button(stdmsgs.BUTTON_CANCEL, cwaction='cancel')]
+
+
+class ChangeStateFormView(form.FormViewMixIn, EntityView):
+    __regid__ = 'statuschange'
+    title = _('status change')
+    __select__ = (one_line_rset()
+                  & match_form_params('treid')
+                  & adaptable('IWorkflowable'))
+
+    def cell_call(self, row, col):
+        entity = self.cw_rset.get_entity(row, col)
+        transition = self._cw.entity_from_eid(self._cw.form['treid'])
+        form = self.get_form(entity, transition)
+        self.w(u'<h4>%s %s</h4>\n' % (self._cw._(transition.name),
+                                      entity.view('oneline')))
+        msg = self._cw._('status will change from %(st1)s to %(st2)s') % {
+            'st1': entity.cw_adapt_to('IWorkflowable').printable_state,
+            'st2': self._cw._(transition.destination(entity).name)}
+        self.w(u'<p>%s</p>\n' % msg)
+        form.render(w=self.w)
+
+    def redirectpath(self, entity):
+        return entity.rest_path()
+
+    def get_form(self, entity, transition, **kwargs):
+        # XXX used to specify both rset/row/col and entity in case implements
+        # selector (and not is_instance) is used on custom form
+        form = self._cw.vreg['forms'].select(
+            'changestate', self._cw, entity=entity, transition=transition,
+            redirect_path=self.redirectpath(entity), **kwargs)
+        trinfo = self._cw.vreg['etypes'].etype_class('TrInfo')(self._cw)
+        trinfo.eid = next(self._cw.varmaker)
+        subform = self._cw.vreg['forms'].select('edition', self._cw, entity=trinfo,
+                                                mainform=False)
+        subform.field_by_name('wf_info_for', 'subject').value = entity.eid
+        trfield = subform.field_by_name('by_transition', 'subject')
+        trfield.widget = fwdgs.HiddenInput()
+        trfield.value = transition.eid
+        form.add_subform(subform)
+        return form
+
+
+class WFHistoryView(EntityView):
+    __regid__ = 'wfhistory'
+    __select__ = relation_possible('wf_info_for', role='object') & \
+                 score_entity(lambda x: x.cw_adapt_to('IWorkflowable').workflow_history)
+
+    title = _('Workflow history')
+
+    def cell_call(self, row, col, view=None, title=title):
+        _ = self._cw._
+        eid = self.cw_rset[row][col]
+        sel = 'Any FS,TS,C,D'
+        rql = ' ORDERBY D DESC WHERE WF wf_info_for X,'\
+              'WF from_state FS, WF to_state TS, WF comment C,'\
+              'WF creation_date D'
+        if self._cw.vreg.schema.eschema('CWUser').has_perm(self._cw, 'read'):
+            sel += ',U,WF'
+            rql += ', WF owned_by U?'
+            headers = (_('from_state'), _('to_state'), _('comment'), _('date'),
+                       _('CWUser'))
+        else:
+            sel += ',WF'
+            headers = (_('from_state'), _('to_state'), _('comment'), _('date'))
+        rql = '%s %s, X eid %%(x)s' % (sel, rql)
+        try:
+            rset = self._cw.execute(rql, {'x': eid})
+        except Unauthorized:
+            return
+        if rset:
+            if title:
+                self.w(u'<h2>%s</h2>\n' % _(title))
+            self.wview('table', rset, headers=headers,
+                       cellvids={2: 'editable-final'})
+
+
+class WFHistoryVComponent(component.EntityCtxComponent):
+    """display the workflow history for entities supporting it"""
+    __regid__ = 'wfhistory'
+    __select__ = component.EntityCtxComponent.__select__ & WFHistoryView.__select__
+    context = 'navcontentbottom'
+    title = _('Workflow history')
+
+    def render_body(self, w):
+        self.entity.view('wfhistory', w=w, title=None)
+
+
+class InContextWithStateView(EntityView):
+    """display incontext view for an entity as well as its current state"""
+    __regid__ = 'incontext-state'
+    __select__ = adaptable('IWorkflowable')
+    def entity_call(self, entity):
+        iwf = entity.cw_adapt_to('IWorkflowable')
+        self.w(u'%s [%s]' % (entity.view('incontext'), iwf.printable_state))
+
+
+# workflow actions #############################################################
+
+class WorkflowActions(action.Action):
+    """fill 'workflow' sub-menu of the actions box"""
+    __regid__ = 'workflow'
+    __select__ = (action.Action.__select__ & one_line_rset() &
+                  relation_possible('in_state'))
+
+    submenu = _('workflow')
+    order = 10
+
+    def fill_menu(self, box, menu):
+        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
+        menu.label = u'%s: %s' % (self._cw._('state'),
+                                  entity.cw_adapt_to('IWorkflowable').printable_state)
+        menu.append_anyway = True
+        super(WorkflowActions, self).fill_menu(box, menu)
+
+    def actual_actions(self):
+        entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0)
+        iworkflowable = entity.cw_adapt_to('IWorkflowable')
+        hastr = False
+        for tr in iworkflowable.possible_transitions():
+            url = entity.absolute_url(vid='statuschange', treid=tr.eid)
+            yield self.build_action(self._cw._(tr.name), url)
+            hastr = True
+        # don't propose to see wf if user can't pass any transition
+        if hastr:
+            wfurl = iworkflowable.current_workflow.absolute_url()
+            yield self.build_action(self._cw._('view workflow'), wfurl)
+        if iworkflowable.workflow_history:
+            wfurl = entity.absolute_url(vid='wfhistory')
+            yield self.build_action(self._cw._('view history'), wfurl)
+
+
+# workflow entity types views ##################################################
+
+_pvs = uicfg.primaryview_section
+_pvs.tag_subject_of(('Workflow', 'initial_state', '*'), 'hidden')
+_pvs.tag_object_of(('*', 'state_of', 'Workflow'), 'hidden')
+_pvs.tag_object_of(('*', 'transition_of', 'Workflow'), 'hidden')
+_pvs.tag_object_of(('*', 'default_workflow', 'Workflow'), 'hidden')
+
+_abaa = uicfg.actionbox_appearsin_addmenu
+_abaa.tag_subject_of(('BaseTransition', 'condition', 'RQLExpression'), False)
+_abaa.tag_subject_of(('State', 'allowed_transition', 'BaseTransition'), False)
+_abaa.tag_object_of(('SubWorkflowExitPoint', 'destination_state', 'State'),
+                    False)
+_abaa.tag_object_of(('State', 'state_of', 'Workflow'), True)
+_abaa.tag_object_of(('BaseTransition', 'transition_of', 'Workflow'), False)
+_abaa.tag_object_of(('Transition', 'transition_of', 'Workflow'), True)
+_abaa.tag_object_of(('WorkflowTransition', 'transition_of', 'Workflow'), True)
+
+class WorkflowPrimaryView(TabbedPrimaryView):
+    __select__ = is_instance('Workflow')
+    tabs = [  _('wf_tab_info'), _('wfgraph'),]
+    default_tab = 'wf_tab_info'
+
+
+class StateInContextView(EntityView):
+    """convenience trick, State's incontext view should not be clickable"""
+    __regid__ = 'incontext'
+    __select__ = is_instance('State')
+
+    def cell_call(self, row, col):
+        self.w(xml_escape(self._cw.view('textincontext', self.cw_rset,
+                                        row=row, col=col)))
+
+class WorkflowTabTextView(PrimaryTab):
+    __regid__ = 'wf_tab_info'
+    __select__ = PrimaryTab.__select__ & one_line_rset() & is_instance('Workflow')
+
+    def render_entity_attributes(self, entity):
+        _ = self._cw._
+        self.w(u'<div>%s</div>' % (entity.printable_value('description')))
+        self.w(u'<span>%s%s</span>' % (_("workflow_of").capitalize(), _(" :")))
+        html = []
+        for e in  entity.workflow_of:
+            view = e.view('outofcontext')
+            if entity.eid == e.default_workflow[0].eid:
+                view += u' <span>[%s]</span>' % _('default_workflow')
+            html.append(view)
+        self.w(', '.join(v for v in html))
+        self.w(u'<h2>%s</h2>' % _("Transition_plural"))
+        rset = self._cw.execute(
+            'Any T,T,DS,T,TT ORDERBY TN WHERE T transition_of WF, WF eid %(x)s,'
+            'T type TT, T name TN, T destination_state DS?', {'x': entity.eid})
+        self.wview('table', rset, 'null',
+                   cellvids={ 1: 'trfromstates', 2: 'outofcontext', 3:'trsecurity',},
+                   headers = (_('Transition'),  _('from_state'),
+                              _('to_state'), _('permissions'), _('type') ),
+                   )
+
+
+class TransitionSecurityTextView(EntityView):
+    __regid__ = 'trsecurity'
+    __select__ = is_instance('Transition')
+
+    def cell_call(self, row, col):
+        _ = self._cw._
+        entity = self.cw_rset.get_entity(self.cw_row, self.cw_col)
+        if entity.require_group:
+            self.w(u'<div>%s%s %s</div>' %
+                   (_('groups'), _(" :"),
+                    u', '.join((g.view('incontext') for g
+                               in entity.require_group))))
+        if entity.condition:
+            self.w(u'<div>%s%s %s</div>' %
+                   ( _('conditions'), _(" :"),
+                     u'<br/>'.join((e.dc_title() for e
+                                in entity.condition))))
+
+class TransitionAllowedTextView(EntityView):
+    __regid__ = 'trfromstates'
+    __select__ = is_instance('Transition')
+
+    def cell_call(self, row, col):
+        entity = self.cw_rset.get_entity(self.cw_row, self.cw_col)
+        self.w(u', '.join((e.view('outofcontext') for e
+                           in entity.reverse_allowed_transition)))
+
+
+# workflow entity types edition ################################################
+
+def _wf_items_for_relation(req, wfeid, wfrelation, field):
+    wf = req.entity_from_eid(wfeid)
+    rschema = req.vreg.schema[field.name]
+    param = 'toeid' if field.role == 'subject' else 'fromeid'
+    return sorted((e.view('combobox'), unicode(e.eid))
+                  for e in getattr(wf, 'reverse_%s' % wfrelation)
+                  if rschema.has_perm(req, 'add', **{param: e.eid}))
+
+# TrInfo
+_afs.tag_subject_of(('TrInfo', 'to_state', '*'), 'main', 'hidden')
+_afs.tag_subject_of(('TrInfo', 'from_state', '*'), 'main', 'hidden')
+_afs.tag_attribute(('TrInfo', 'tr_count'), 'main', 'hidden')
+
+# BaseTransition
+# XXX * allowed_transition BaseTransition
+# XXX BaseTransition destination_state *
+
+def transition_states_vocabulary(form, field):
+    entity = form.edited_entity
+    if entity.has_eid():
+        wfeid = entity.transition_of[0].eid
+    else:
+        eids = form.linked_to.get(('transition_of', 'subject'))
+        if not eids:
+            return []
+        wfeid = eids[0]
+    return _wf_items_for_relation(form._cw, wfeid, 'state_of', field)
+
+_afs.tag_subject_of(('*', 'destination_state', '*'), 'main', 'attributes')
+_affk.tag_subject_of(('*', 'destination_state', '*'),
+                     {'choices': transition_states_vocabulary})
+_afs.tag_object_of(('*', 'allowed_transition', '*'), 'main', 'attributes')
+_affk.tag_object_of(('*', 'allowed_transition', '*'),
+                     {'choices': transition_states_vocabulary})
+
+# State
+
+def state_transitions_vocabulary(form, field):
+    entity = form.edited_entity
+    if entity.has_eid():
+        wfeid = entity.state_of[0].eid
+    else :
+        eids = form.linked_to.get(('state_of', 'subject'))
+        if not eids:
+            return []
+        wfeid = eids[0]
+    return _wf_items_for_relation(form._cw, wfeid, 'transition_of', field)
+
+_afs.tag_subject_of(('State', 'allowed_transition', '*'), 'main', 'attributes')
+_affk.tag_subject_of(('State', 'allowed_transition', '*'),
+                     {'choices': state_transitions_vocabulary})
+
+
+# adaptaters ###################################################################
+
+class WorkflowIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter):
+    __select__ = is_instance('Workflow')
+    # XXX what if workflow of multiple types?
+    def parent_entity(self):
+        return self.entity.workflow_of and self.entity.workflow_of[0] or None
+
+class WorkflowItemIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter):
+    __select__ = is_instance('BaseTransition', 'State')
+    def parent_entity(self):
+        return self.entity.workflow
+
+class TransitionItemIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter):
+    __select__ = is_instance('SubWorkflowExitPoint')
+    def parent_entity(self):
+        return self.entity.reverse_subworkflow_exit[0]
+
+class TrInfoIBreadCrumbsAdapter(ibreadcrumbs.IBreadCrumbsAdapter):
+    __select__ = is_instance('TrInfo')
+    def parent_entity(self):
+        return self.entity.for_entity
+
+
+# workflow images ##############################################################
+
+class WorkflowDotPropsHandler(DotPropsHandler):
+
+    def node_properties(self, stateortransition):
+        """return default DOT drawing options for a state or transition"""
+        props = super(WorkflowDotPropsHandler, self).node_properties(stateortransition)
+        if hasattr(stateortransition, 'state_of'):
+            props['shape'] = 'box'
+            props['style'] = 'filled'
+            if stateortransition.reverse_initial_state:
+                props['fillcolor'] = '#88CC88'
+        else:
+            props['shape'] = 'ellipse'
+        return props
+
+
+class WorkflowVisitor(object):
+    def __init__(self, entity):
+        self.entity = entity
+
+    def nodes(self):
+        for state in self.entity.reverse_state_of:
+            state.complete()
+            yield state.eid, state
+        for transition in self.entity.reverse_transition_of:
+            transition.complete()
+            yield transition.eid, transition
+
+    def edges(self):
+        for transition in self.entity.reverse_transition_of:
+            for incomingstate in transition.reverse_allowed_transition:
+                yield incomingstate.eid, transition.eid, transition
+            for outgoingstate in transition.potential_destinations():
+                yield transition.eid, outgoingstate.eid, transition
+
+class WorkflowGraphView(DotGraphView):
+    __regid__ = 'wfgraph'
+    __select__ = EntityView.__select__ & one_line_rset() & is_instance('Workflow')
+
+    def build_visitor(self, entity):
+        return WorkflowVisitor(entity)
+
+    def build_dotpropshandler(self):
+        return WorkflowDotPropsHandler(self._cw)
+
+
+@add_metaclass(class_deprecated)
+class TmpPngView(TmpFileViewMixin, EntityView):
+    __deprecation_warning__ = '[3.18] %(cls)s is deprecated'
+    __regid__ = 'tmppng'
+    __select__ = match_form_params('tmpfile')
+    content_type = 'image/png'
+    binary = True
+
+    def cell_call(self, row=0, col=0):
+        key = self._cw.form['tmpfile']
+        if key not in self._cw.session.data:
+            # the temp file is gone and there's nothing
+            # we can do about it
+            # we should probably write it to some well
+            # behaved place and serve it
+            return
+        tmpfile = self._cw.session.data.pop(key)
+        self.w(open(tmpfile, 'rb').read())
+        os.unlink(tmpfile)