Added the concept of 'fake' workflow transitions: when a user can't trigger a transition but needs an explanation about why he can't, a fake button is shown, with a explanation.

This commit is contained in:
Gaetan Delannay 2011-02-01 11:09:54 +01:00
parent b48525c5bb
commit 77112c45be
11 changed files with 141 additions and 34 deletions

View file

@ -2054,6 +2054,13 @@ class Transition:
Else, returns False.''' Else, returns False.'''
return isinstance(self.states[0], State) return isinstance(self.states[0], State)
def isShowable(self, workflow, obj):
'''Is this transition showable?'''
if callable(self.show):
return self.show(workflow, obj.appy())
else:
return self.show
def getStates(self, fromStates=True): def getStates(self, fromStates=True):
'''Returns the fromState(s) if p_fromStates is True, the toState(s) '''Returns the fromState(s) if p_fromStates is True, the toState(s)
else. If you want to get the states grouped in tuples else. If you want to get the states grouped in tuples
@ -2204,4 +2211,6 @@ class Config:
# define it, we will add a copy of the instance defined below. # define it, we will add a copy of the instance defined below.
title = String(multiplicity=(1,1), show='edit') title = String(multiplicity=(1,1), show='edit')
title.init('title', None, 'appy') title.init('title', None, 'appy')
state = String()
state.init('state', None, 'appy')
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -24,6 +24,7 @@ class ToolMixin(BaseMixin):
'''Returns the name of the portal_type that is based on '''Returns the name of the portal_type that is based on
p_metaTypeOrAppyType.''' p_metaTypeOrAppyType.'''
appName = self.getProductConfig().PROJECTNAME appName = self.getProductConfig().PROJECTNAME
res = metaTypeOrAppyClass
if not isinstance(metaTypeOrAppyClass, basestring): if not isinstance(metaTypeOrAppyClass, basestring):
res = getClassName(metaTypeOrAppyClass, appName) res = getClassName(metaTypeOrAppyClass, appName)
if res.find('Extensions_appyWrappers') != -1: if res.find('Extensions_appyWrappers') != -1:

View file

@ -5,11 +5,11 @@
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
import os, os.path, sys, types, mimetypes, urllib import os, os.path, sys, types, mimetypes, urllib
import appy.gen import appy.gen
from appy.gen import Type, String, Selection, Role from appy.gen import Type, String, Selection, Role, No
from appy.gen.utils import * from appy.gen.utils import *
from appy.gen.layout import Table, defaultPageLayouts from appy.gen.layout import Table, defaultPageLayouts
from appy.gen.plone25.descriptors import ClassDescriptor from appy.gen.plone25.descriptors import ClassDescriptor
from appy.gen.plone25.utils import updateRolesForPermission from appy.gen.plone25.utils import updateRolesForPermission,checkTransitionGuard
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
class BaseMixin: class BaseMixin:
@ -342,6 +342,7 @@ class BaseMixin:
def showField(self, name, layoutType='view'): def showField(self, name, layoutType='view'):
'''Must I show field named p_name on this p_layoutType ?''' '''Must I show field named p_name on this p_layoutType ?'''
if name == 'state': return False
return self.getAppyType(name).isShowable(self, layoutType) return self.getAppyType(name).isShowable(self, layoutType)
def getMethod(self, methodName): def getMethod(self, methodName):
@ -560,24 +561,66 @@ class BaseMixin:
res.append(StateDescr(stateName, stateStatus).get()) res.append(StateDescr(stateName, stateStatus).get())
return res return res
def getAppyTransitions(self): def getAppyTransitions(self, includeFake=True, includeNotShowable=False):
'''Returns the transitions that the user can trigger on p_self.''' '''This method is similar to portal_workflow.getTransitionsFor, but:
transitions = self.portal_workflow.getTransitionsFor(self) * is able (or not, depending on boolean p_includeFake) to retrieve
transitions that the user can't trigger, but for which he needs to
know for what reason he can't trigger it;
* is able (or not, depending on p_includeNotShowable) to include
transitions for which show=False at the Appy level. Indeed, because
"showability" is only a GUI concern, and not a security concern,
in some cases it has sense to set includeNotShowable=True, because
those transitions are triggerable from a security point of view;
* the transition-info is richer: it contains fake-related info (as
described above) and confirm-related info (ie, when clicking on
the button, do we ask the user to confirm via a popup?)'''
res = [] res = []
if transitions: # Get some Plone stuff from the Plone-level config.py
# Retrieve the corresponding Appy transition, to check if the user TRIGGER_USER_ACTION = self.getProductConfig().TRIGGER_USER_ACTION
# may view it. sm = self.getProductConfig().getSecurityManager
workflow = self.getWorkflow(appy=True) # Get the workflow definition for p_obj.
if not workflow: return transitions workflow = self.getWorkflow(appy=False)
for transition in transitions: if not workflow: return res
# Get the corresponding Appy transition appyWorkflow = self.getWorkflow(appy=True)
appyTr = workflow._transitionsMapping[transition['id']] # What is the current state for this object?
if self._appy_showTransition(workflow, appyTr.show): currentState = workflow._getWorkflowStateOf(self)
transition['confirm'] = '' if not currentState: return res
if appyTr.confirm: # Analyse all the transitions that start from this state.
label = '%s_confirm' % transition['name'] for transitionId in currentState.transitions:
transition['confirm']=self.translate(label, format='js') transition = workflow.transitions.get(transitionId, None)
res.append(transition) appyTr = appyWorkflow._transitionsMapping[transitionId]
if not transition or (transition.trigger_type!=TRIGGER_USER_ACTION)\
or not transition.actbox_name: continue
# We have a possible candidate for a user-triggerable transition
if transition.guard is None:
mayTrigger = True
else:
mayTrigger = checkTransitionGuard(transition.guard, sm(),
workflow, self)
# Compute the condition that will lead to including or not this
# transition
if not includeFake:
includeIt = mayTrigger
else:
includeIt = mayTrigger or isinstance(mayTrigger, No)
if not includeNotShowable:
includeIt = includeIt and appyTr.isShowable(appyWorkflow, self)
if not includeIt: continue
# Add transition-info to the result.
tInfo = {'id': transition.id, 'title': transition.title,
'title_or_id': transition.title_or_id(),
'description': transition.description, 'confirm': '',
'name': transition.actbox_name, 'may_trigger': True,
'url': transition.actbox_url %
{'content_url': self.absolute_url(),
'portal_url' : '', 'folder_url' : ''}}
if appyTr.confirm:
label = '%s_confirm' % tInfo['name']
tInfo['confirm'] = self.translate(label, format='js')
if not mayTrigger:
tInfo['may_trigger'] = False
tInfo['reason'] = mayTrigger.msg
res.append(tInfo)
return res return res
def getAppyPhases(self, currentOnly=False, layoutType='view'): def getAppyPhases(self, currentOnly=False, layoutType='view'):
@ -947,12 +990,6 @@ class BaseMixin:
return stateShow(workflow, self.appy()) return stateShow(workflow, self.appy())
else: return stateShow else: return stateShow
def _appy_showTransition(self, workflow, transitionShow):
'''Must I show a transition whose "show value" is p_transitionShow?'''
if callable(transitionShow):
return transitionShow(workflow, self.appy())
else: return transitionShow
def _appy_managePermissions(self): def _appy_managePermissions(self):
'''When an object is created or updated, we must update "add" '''When an object is created or updated, we must update "add"
permissions accordingly: if the object is a folder, we must set on permissions accordingly: if the object is a folder, we must set on

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 B

View file

@ -528,9 +528,15 @@
<tal:comment replace="nothing">Buttons for triggering transitions</tal:comment> <tal:comment replace="nothing">Buttons for triggering transitions</tal:comment>
<td align="right" tal:repeat="transition transitions"> <td align="right" tal:repeat="transition transitions">
<input type="button" class="appyButton" <tal:comment replace="nothing">Real button</tal:comment>
<input type="button" class="appyButton" tal:condition="transition/may_trigger"
tal:attributes="value python: tool.translate(transition['name']); tal:attributes="value python: tool.translate(transition['name']);
onClick python: 'triggerTransition(\'%s\',\'%s\')' % (transition['id'],transition['confirm']);"/> onClick python: 'triggerTransition(\'%s\',\'%s\')' % (transition['id'],transition['confirm']);"/>
<tal:comment replace="nothing">Fake button, explaining why the transition can't be triggered</tal:comment>
<div class="appyButton fakeButton" tal:condition="not: transition/may_trigger">
<acronym tal:content="python: tool.translate(transition['name'])"
tal:attributes="title transition/reason"></acronym>
</div>
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -34,10 +34,9 @@
</tal:publishedObject> </tal:publishedObject>
<tal:comment replace="nothing">Create a section for every root class.</tal:comment> <tal:comment replace="nothing">Create a section for every root class.</tal:comment>
<tal:section repeat="rootClass rootClasses"> <tal:section repeat="rootClass python: [rc for rc in rootClasses if tool.userMaySearch(rc)]">
<tal:comment replace="nothing">Section title, with action icons</tal:comment> <tal:comment replace="nothing">Section title, with action icons</tal:comment>
<dt tal:condition="python: tool.userMaySearch(rootClass)" <dt tal:attributes="class python:test((repeat['rootClass'].number()==1) and not contextObj, 'portletAppyItem', 'portletAppyItem portletSep')">
tal:attributes="class python:test((repeat['rootClass'].number()==1) and not contextObj, 'portletAppyItem', 'portletAppyItem portletSep')">
<table width="100%" cellspacing="0" cellpadding="0" class="no-style-table"> <table width="100%" cellspacing="0" cellpadding="0" class="no-style-table">
<tr> <tr>
<td> <td>

View file

@ -91,12 +91,12 @@
<tal:comment replace="nothing">Workflow state</tal:comment> <tal:comment replace="nothing">Workflow state</tal:comment>
<td id="field_workflow_state" <td id="field_workflow_state"
tal:condition="python: widget['name'] == 'workflowState'" tal:condition="python: widget['name'] == 'state'"
tal:content="python: tool.translate(obj.getWorkflowLabel())"> tal:content="python: tool.translate(obj.getWorkflowLabel())">
</td> </td>
<tal:comment replace="nothing">Any other field</tal:comment> <tal:comment replace="nothing">Any other field</tal:comment>
<td tal:condition="python: widget['name'] not in ('title', 'workflowState')" <td tal:condition="python: widget['name'] not in ('title', 'state')"
tal:attributes="id python:'field_%s' % widget['name']"> tal:attributes="id python:'field_%s' % widget['name']">
<tal:field define="contextObj python:obj; <tal:field define="contextObj python:obj;
layoutType python:'cell'; layoutType python:'cell';

View file

@ -180,6 +180,11 @@ th {
/* overflow: visible; IE produces ugly results with this */ /* overflow: visible; IE produces ugly results with this */
} }
.fakeButton {
background: #ffd5c0 url(&dtml-portal_url;/skyn/fakeTransition.gif) 5px 1px no-repeat;
padding: 3px 4px 3px 12px;
}
.listing { margin: 0em 0em; } .listing { margin: 0em 0em; }
.listing td, .stx table td { .listing td, .stx table td {
padding : 0.1em 0.3em 0.1em 0.3em; padding : 0.1em 0.3em 0.1em 0.3em;

View file

@ -18,6 +18,7 @@ except ImportError:
CustomizationPolicy = None CustomizationPolicy = None
from OFS.Image import File from OFS.Image import File
from ZPublisher.HTTPRequest import FileUpload from ZPublisher.HTTPRequest import FileUpload
from AccessControl import getSecurityManager
from DateTime import DateTime from DateTime import DateTime
from Products.CMFCore import utils as cmfutils from Products.CMFCore import utils as cmfutils
from Products.CMFCore.utils import getToolByName from Products.CMFCore.utils import getToolByName
@ -25,6 +26,7 @@ from Products.CMFPlone.PloneBatch import Batch
from Products.CMFPlone.utils import ToolInit from Products.CMFPlone.utils import ToolInit
from Products.CMFCore import DirectoryView from Products.CMFCore import DirectoryView
from Products.CMFCore.DirectoryView import manage_addDirectoryView from Products.CMFCore.DirectoryView import manage_addDirectoryView
from Products.DCWorkflow.Transitions import TRIGGER_USER_ACTION
from Products.ExternalMethod.ExternalMethod import ExternalMethod from Products.ExternalMethod.ExternalMethod import ExternalMethod
from Products.Archetypes.Extensions.utils import installTypes from Products.Archetypes.Extensions.utils import installTypes
from Products.Archetypes.Extensions.utils import install_subskin from Products.Archetypes.Extensions.utils import install_subskin

View file

@ -33,4 +33,52 @@ def updateRolesForPermission(permission, roles, obj):
existingRoles = perm.getRoles() existingRoles = perm.getRoles()
allRoles = set(existingRoles).union(roles) allRoles = set(existingRoles).union(roles)
obj.manage_permission(permission, tuple(allRoles), acquire=0) obj.manage_permission(permission, tuple(allRoles), acquire=0)
# ------------------------------------------------------------------------------
def checkTransitionGuard(guard, sm, wf_def, ob):
'''This method is similar to DCWorkflow.Guard.check, but allows to retrieve
the truth value as a appy.gen.No instance, not simply "1" or "0".'''
from Products.DCWorkflow.Expression import StateChangeInfo,createExprContext
u_roles = None
if wf_def.manager_bypass:
# Possibly bypass.
u_roles = sm.getUser().getRolesInContext(ob)
if 'Manager' in u_roles:
return 1
if guard.permissions:
for p in guard.permissions:
if _checkPermission(p, ob):
break
else:
return 0
if guard.roles:
# Require at least one of the given roles.
if u_roles is None:
u_roles = sm.getUser().getRolesInContext(ob)
for role in guard.roles:
if role in u_roles:
break
else:
return 0
if guard.groups:
# Require at least one of the specified groups.
u = sm.getUser()
b = aq_base( u )
if hasattr( b, 'getGroupsInContext' ):
u_groups = u.getGroupsInContext( ob )
elif hasattr( b, 'getGroups' ):
u_groups = u.getGroups()
else:
u_groups = ()
for group in guard.groups:
if group in u_groups:
break
else:
return 0
expr = guard.expr
if expr is not None:
econtext = createExprContext(StateChangeInfo(ob, wf_def))
res = expr(econtext)
return res
return 1
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -222,11 +222,11 @@ class AbstractWrapper:
will be no trace from this transition triggering in the workflow will be no trace from this transition triggering in the workflow
history.''' history.'''
wfTool = self.o.portal_workflow wfTool = self.o.portal_workflow
availableTransitions = [t['id'] for t in \ availableTransitions = [t['id'] for t in self.o.getAppyTransitions(\
wfTool.getTransitionsFor(self.o)] includeFake=False, includeNotShowable=True)]
transitionName = transition transitionName = transition
if not transitionName in availableTransitions: if not transitionName in availableTransitions:
# Maybe is is a compound Appy transition. Try to find the # Maybe is it a compound Appy transition. Try to find the
# corresponding DC transition. # corresponding DC transition.
state = self.state state = self.state
transitionPrefix = transition + state[0].upper() + state[1:] + 'To' transitionPrefix = transition + state[0].upper() + state[1:] + 'To'