diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/views/workflow.py --- /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 . +"""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'

%s %s

\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'

%s

\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'

%s

\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'
%s
' % (entity.printable_value('description'))) + self.w(u'%s%s' % (_("workflow_of").capitalize(), _(" :"))) + html = [] + for e in entity.workflow_of: + view = e.view('outofcontext') + if entity.eid == e.default_workflow[0].eid: + view += u' [%s]' % _('default_workflow') + html.append(view) + self.w(', '.join(v for v in html)) + self.w(u'

%s

' % _("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'
%s%s %s
' % + (_('groups'), _(" :"), + u', '.join((g.view('incontext') for g + in entity.require_group)))) + if entity.condition: + self.w(u'
%s%s %s
' % + ( _('conditions'), _(" :"), + u'
'.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)