diff --git a/doc/version.txt b/doc/version.txt index 2228cad..faef31a 100644 --- a/doc/version.txt +++ b/doc/version.txt @@ -1 +1 @@ -0.6.7 +0.7.0 diff --git a/gen/__init__.py b/gen/__init__.py index d2ede7a..71b421b 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ -import re, time, copy, sys, types, os, os.path, mimetypes, StringIO +import re, time, copy, sys, types, os, os.path, mimetypes, string, StringIO from appy.gen.layout import Table from appy.gen.layout import defaultFieldLayouts from appy.gen.po import PoMessage @@ -2167,7 +2167,13 @@ class Pod(Type): value = value._atFile setattr(obj, self.name, value) -# Workflow-specific types ------------------------------------------------------ +# Workflow-specific types and default workflows -------------------------------- +appyToZopePermissions = { + 'read': ('View', 'Access contents information'), + 'write': 'Modify portal content', + 'delete': 'Delete objects', +} + class Role: '''Represents a role.''' ploneRoles = ('Manager', 'Member', 'Owner', 'Reviewer', 'Anonymous', @@ -2201,6 +2207,12 @@ class State: # Standardize the way roles are expressed within self.permissions self.standardizeRoles() + def getName(self, wf): + '''Returns the name for this state in workflow p_wf.''' + for name in dir(wf): + value = getattr(wf, name) + if (value == self): return name + def getRole(self, role): '''p_role can be the name of a role or a Role instance. If it is the name of a role, this method returns self.usedRoles[role] if it @@ -2233,16 +2245,6 @@ class State: def getUsedRoles(self): return self.usedRoles.values() - def getTransitions(self, transitions, selfIsFromState=True): - '''Among p_transitions, returns those whose fromState is p_self (if - p_selfIsFromState is True) or those whose toState is p_self (if - p_selfIsFromState is False).''' - res = [] - for t in transitions: - if self in t.getStates(selfIsFromState): - res.append(t) - return res - def getPermissions(self): '''If you get the permissions mapping through self.permissions, dict values may be of different types (a list of roles, a single role or @@ -2258,6 +2260,38 @@ class State: res[permission] = roleValue return res + def updatePermission(self, obj, zopePermission, roleNames): + '''Updates, on p_obj, list of p_roleNames which are granted a given + p_zopePermission.''' + attr = Permission.getZopeAttrName(zopePermission) + if not hasattr(obj.aq_base, attr) or \ + (getattr(obj.aq_base, attr) != roleNames): + setattr(obj, attr, roleNames) + + def updatePermissions(self, wf, obj): + '''Zope requires permission-to-roles mappings to be stored as attributes + on the object itself. This method does this job, duplicating the info + from this state on p_obj.''' + for permission, roles in self.getPermissions().iteritems(): + roleNames = tuple([role.name for role in roles]) + # Compute Zope permission(s) related to this permission. + if appyToZopePermissions.has_key(permission): + # It is a standard permission (r, w, d) + zopePerm = appyToZopePermissions[permission] + elif isinstance(permission, basestring): + # It is a user-defined permission + zopePerm = permission + else: + # It is a Permission instance + appName = obj.getProductConfig().PROJECTNAME + zopePerm = permission.getName(wf, appName) + # zopePerm contains a single permission or a tuple of permissions + if isinstance(zopePerm, basestring): + self.updatePermission(obj, zopePerm, roleNames) + else: + for zPerm in zopePerm: + self.updatePermission(obj, zPerm, roleNames) + class Transition: def __init__(self, states, condition=True, action=None, notify=None, show=True, confirm=False): @@ -2277,6 +2311,12 @@ class Transition: # the transition. It will only be possible by code. self.confirm = confirm # If True, a confirm popup will show up. + def getName(self, wf): + '''Returns the name for this state in workflow p_wf.''' + for name in dir(wf): + value = getattr(wf, name) + if (value == self): return name + def getUsedRoles(self): '''self.condition can specify a role.''' res = [] @@ -2296,23 +2336,6 @@ class Transition: else: return self.show - def getStates(self, fromStates=True): - '''Returns the fromState(s) if p_fromStates is True, the toState(s) - else. If you want to get the states grouped in tuples - (fromState, toState), simply use self.states.''' - res = [] - stateIndex = 1 - if fromStates: - stateIndex = 0 - if self.isSingle(): - res.append(self.states[stateIndex]) - else: - for states in self.states: - theState = states[stateIndex] - if theState not in res: - res.append(theState) - return res - def hasState(self, state, isFrom): '''If p_isFrom is True, this method returns True if p_state is a starting state for p_self. If p_isFrom is False, this method returns @@ -2330,6 +2353,117 @@ class Transition: break return res + def isTriggerable(self, obj, wf): + '''Can this transition be triggered on p_obj?''' + # Checks that the current state of the object is a start state for this + # transition. + objState = obj.getState(name=False) + if self.isSingle(): + if objState != self.states[0]: return False + else: + startFound = False + for startState, stopState in self.states: + if startState == objState: + startFound = True + break + if not startFound: return False + # Check that the condition is met + user = obj.portal_membership.getAuthenticatedMember() + if isinstance(self.condition, Role): + # Condition is a role. Transition may be triggered if the user has + # this role. + return user.has_role(self.condition.name, obj) + elif type(self.condition) == types.FunctionType: + return self.condition(wf, obj.appy()) + elif type(self.condition) in (tuple, list): + # It is a list of roles and/or functions. Transition may be + # triggered if user has at least one of those roles and if all + # functions return True. + hasRole = None + for roleOrFunction in self.condition: + if isinstance(roleOrFunction, basestring): + if hasRole == None: + hasRole = False + if user.has_role(roleOrFunction, obj): + hasRole = True + elif type(roleOrFunction) == types.FunctionType: + if not roleOrFunction(wf, obj.appy()): + return False + if hasRole != False: + return True + + def executeAction(self, obj, wf): + '''Executes the action related to this transition.''' + msg = '' + if type(self.action) in (tuple, list): + # We need to execute a list of actions + for act in self.action: + msgPart = act(wf, obj.appy()) + if msgPart: msg += msgPart + else: # We execute a single action only. + msgPart = self.action(wf, obj.appy()) + if msgPart: msg += msgPart + return msg + + def trigger(self, transitionName, obj, wf, comment, doAction=True, + doNotify=True, doHistory=True, doSay=True): + '''This method triggers this transition on p_obj. The transition is + supposed to be triggerable (call to self.isTriggerable must have been + performed before calling this method). If p_doAction is False, the + action that must normally be executed after the transition has been + triggered will not be executed. If p_doNotify is False, the + notifications (email,...) that must normally be launched after the + transition has been triggered will not be launched. If p_doHistory is + False, there will be no trace from this transition triggering in the + workflow history. If p_doSay is False, we consider the transition is + trigger programmatically, and no message is returned to the user.''' + # Create the workflow_history dict if it does not exist. + if not hasattr(obj.aq_base, 'workflow_history'): + from persistent.mapping import PersistentMapping + obj.workflow_history = PersistentMapping() + # Create the event list if it does not exist in the dict + if not obj.workflow_history: obj.workflow_history['appy'] = () + # Get the key where object history is stored (this overstructure is + # only there for backward compatibility reasons) + key = obj.workflow_history.keys()[0] + # Identify the target state for this transition + if self.isSingle(): + targetState = self.states[1] + targetStateName = targetState.getName(wf) + else: + startState = obj.getState(name=False) + for sState, tState in self.states: + if startState == sState: + targetState = tState + targetStateName = targetState.getName(wf) + break + # Create the event and put it in workflow_history + from DateTime import DateTime + action = transitionName + if transitionName == '_init_': action = None + userId = obj.portal_membership.getAuthenticatedMember().getId() + if not doHistory: comment = '_invisible_' + obj.workflow_history[key] += ( + {'action':action, 'review_state': targetStateName, + 'comments': comment, 'actor': userId, 'time': DateTime()},) + # Update permissions-to-roles attributes + targetState.updatePermissions(wf, obj) + # Refresh catalog-related security if required + if not obj.isTemporary(): + obj.reindexObject(idxs=('allowedRolesAndUsers','review_state')) + # Execute the related action if needed + msg = '' + if doAction and self.action: msg = self.executeAction(obj, wf) + # Send notifications if needed + if doNotify and self.notify and obj.getTool(True).enableNotifications: + notifier.sendMail(obj.appy(), self, transitionName, wf) + # Return a message to the user if needed + if not doSay or (transitionName == '_init_'): return + if not msg: + msg = obj.translate(u'Your content\'s status has been modified.', + domain='plone') + obj.say(msg) + class Permission: '''If you need to define a specific read or write permission of a given attribute of an Appy type, you use the specific boolean parameters @@ -2346,9 +2480,37 @@ class Permission: and "specificWritePermission" as booleans. When defining named (string) permissions, for referring to it you simply use those strings, you do not create instances of ReadPermission or WritePermission.''' + + allowedChars = string.digits + string.letters + '_' + def __init__(self, fieldDescriptor): self.fieldDescriptor = fieldDescriptor + def getName(self, wf, appName): + '''Returns the name of the Zope permission that corresponds to this + permission.''' + className, fieldName = self.fieldDescriptor.rsplit('.', 1) + if className.find('.') == -1: + # The related class resides in the same module as the workflow + fullClassName= '%s_%s' % (wf.__module__.replace('.', '_'),className) + else: + # className contains the full package name of the class + fullClassName = className.replace('.', '_') + # Read or Write ? + if self.__class__.__name__ == 'ReadPermission': access = 'Read' + else: access = 'Write' + return '%s: %s %s %s' % (appName, access, fullClassName, fieldName) + + @staticmethod + def getZopeAttrName(zopePermission): + '''Gets the name of the attribute where Zope stores, on every object, + the tuple of roles who are granted a given p_zopePermission.''' + res = '' + for c in zopePermission: + if c in Permission.allowedChars: res += c + else: res += '_' + return '_%s_Permission' % res + class ReadPermission(Permission): pass class WritePermission(Permission): pass @@ -2363,6 +2525,17 @@ class No: def __nonzero__(self): return False +class WorkflowAnonymous: + '''One-state workflow allowing anyone to consult and Manager to edit.''' + mgr = 'Manager' + active = State({r:[mgr, 'Anonymous'], w:mgr, d:mgr}, initial=True) + +class WorkflowAuthenticated: + '''One-state workflow allowing authenticated users to consult and Manager + to edit.''' + mgr = 'Manager' + active = State({r:[mgr, 'Authenticated'], w:mgr, d:mgr}, initial=True) + # ------------------------------------------------------------------------------ class Selection: '''Instances of this class may be given as validator of a String, in order diff --git a/gen/descriptors.py b/gen/descriptors.py index 6a67f10..550f329 100644 --- a/gen/descriptors.py +++ b/gen/descriptors.py @@ -89,141 +89,9 @@ class ClassDescriptor(Descriptor): class WorkflowDescriptor(Descriptor): '''This class gives information about an Appy workflow.''' - - def _getWorkflowElements(self, elemType): - res = [] - for attrName in dir(self.klass): - attrValue = getattr(self.klass, attrName) - condition = False - if elemType == 'states': - condition = isinstance(attrValue, State) - elif elemType == 'transitions': - condition = isinstance(attrValue, Transition) - elif elemType == 'all': - condition = isinstance(attrValue, State) or \ - isinstance(attrValue, Transition) - if condition: - res.append(attrValue) - return res - - def getStates(self): - return self._getWorkflowElements('states') - - def getTransitions(self): - return self._getWorkflowElements('transitions') - - def getStateNames(self, ordered=False): - res = [] - attrs = dir(self.klass) - allAttrs = attrs - if ordered: - attrs = self.orderedAttributes - allAttrs = dir(self.klass) - for attrName in attrs: - attrValue = getattr(self.klass, attrName) - if isinstance(attrValue, State): - res.append(attrName) - # Complete the list with inherited states. For the moment, we are unable - # to sort inherited states. - for attrName in allAttrs: - attrValue = getattr(self.klass, attrName) - if isinstance(attrValue, State) and (attrName not in attrs): - res.insert(0, attrName) - return res - - def getInitialStateName(self): - res = None - for attrName in dir(self.klass): - attrValue = getattr(self.klass, attrName) - if isinstance(attrValue, State) and attrValue.initial: - res = attrName - break - return res - - def getTransitionNamesOf(self, transitionName, transition, - limitToFromState=None): - '''Appy p_transition may correspond to several transitions of the - concrete workflow engine used. This method returns in a list the - name(s) of the "concrete" transition(s) corresponding to - p_transition.''' - res = [] - if transition.isSingle(): - res.append(transitionName) - else: - for fromState, toState in transition.states: - if not limitToFromState or \ - (limitToFromState and (fromState == limitToFromState)): - fromStateName = self.getNameOf(fromState) - toStateName = self.getNameOf(toState) - res.append('%s%s%sTo%s%s' % (transitionName, - fromStateName[0].upper(), fromStateName[1:], - toStateName[0].upper(), toStateName[1:])) - return res - - def getTransitionNames(self, limitToTransitions=None, limitToFromState=None, - withLabels=False): - '''Returns the name of all "concrete" transitions corresponding to the - Appy transitions of this worlflow. If p_limitToTransitions is not - None, it represents a list of Appy transitions and the result is a - list of the names of the "concrete" transitions that correspond to - those transitions only. If p_limitToFromState is not None, it - represents an Appy state; only transitions having this state as start - state will be taken into account. If p_withLabels is True, the method - returns a list of tuples (s_transitionName, s_transitionLabel); the - label being the name of the Appy transition.''' - res = [] - for attrName in dir(self.klass): - attrValue = getattr(self.klass, attrName) - if isinstance(attrValue, Transition): - # We encountered a transition. - t = attrValue - tName = attrName - if not limitToTransitions or \ - (limitToTransitions and t in limitToTransitions): - # We must take this transition into account according to - # param "limitToTransitions". - if (not limitToFromState) or \ - (limitToFromState and \ - t.hasState(limitToFromState, isFrom=True)): - # We must take this transition into account according - # to param "limitToFromState" - tNames = self.getTransitionNamesOf( - tName, t, limitToFromState) - if not withLabels: - res += tNames - else: - for tn in tNames: - res.append((tn, tName)) - return res - - def getEndStateName(self, transitionName): - '''Returns the name of the state where the "concrete" transition named - p_transitionName ends.''' - res = None - for attrName in dir(self.klass): - attrValue = getattr(self.klass, attrName) - if isinstance(attrValue, Transition): - # We got a transition. - t = attrValue - tName = attrName - if t.isSingle(): - if transitionName == tName: - endState = t.states[1] - res = self.getNameOf(endState) - else: - transNames = self.getTransitionNamesOf(tName, t) - if transitionName in transNames: - endState = t.states[transNames.index(transitionName)][1] - res = self.getNameOf(endState) - return res - - def getNameOf(self, stateOrTransition): - '''Gets the Appy name of a p_stateOrTransition.''' - res = None - for attrName in dir(self.klass): - attrValue = getattr(self.klass, attrName) - if attrValue == stateOrTransition: - res = attrName - break - return res + @staticmethod + def getWorkflowName(klass): + '''Returns the name of this workflow.''' + res = klass.__module__.replace('.', '_') + '_' + klass.__name__ + return res.lower() # ------------------------------------------------------------------------------ diff --git a/gen/plone25/descriptors.py b/gen/plone25/descriptors.py index cbfbed2..d7e8135 100644 --- a/gen/plone25/descriptors.py +++ b/gen/plone25/descriptors.py @@ -598,122 +598,4 @@ class TranslationClassDescriptor(ClassDescriptor): params['format'] = String.TEXT params['height'] = height self.addField(messageId, String(**params)) - -class WorkflowDescriptor(appy.gen.descriptors.WorkflowDescriptor): - '''Represents a workflow.''' - # How to map Appy permissions to Plone permissions ? - appyToPlonePermissions = { - 'read': ('View', 'Access contents information'), - 'write': ('Modify portal content',), - 'delete': ('Delete objects',), - } - def getPlonePermissions(self, permission): - '''Returns the Plone permission(s) that correspond to - Appy p_permission.''' - if self.appyToPlonePermissions.has_key(permission): - res = self.appyToPlonePermissions[permission] - elif isinstance(permission, basestring): - res = [permission] - else: - # Permission if an Appy permission declaration - className, fieldName = permission.fieldDescriptor.rsplit('.', 1) - if className.find('.') == -1: - # The related class resides in the same module as the workflow - fullClassName = '%s_%s' % ( - self.klass.__module__.replace('.', '_'), className) - else: - # className contains the full package name of the class - fullClassName = className.replace('.', '_') - # Read or Write ? - if permission.__class__.__name__ == 'ReadPermission': - access = 'Read' - else: - access = 'Write' - permName = '%s: %s %s %s' % (self.generator.applicationName, - access, fullClassName, fieldName) - res = [permName] - return res - - def getWorkflowName(klass): - '''Generates the name of the corresponding Archetypes workflow.''' - res = klass.__module__.replace('.', '_') + '_' + klass.__name__ - return res.lower() - getWorkflowName = staticmethod(getWorkflowName) - - def getStatesInfo(self, asDumpableCode=False): - '''Gets, in a dict, information for configuring states of the workflow. - If p_asDumpableCode is True, instead of returning a dict, this - method will return a string containing the dict that can be dumped - into a Python code file.''' - res = {} - transitions = self.getTransitions() - for state in self.getStates(): - stateName = self.getNameOf(state) - # We need the list of transitions that start from this state - outTransitions = state.getTransitions(transitions, - selfIsFromState=True) - tNames = self.getTransitionNames(outTransitions, - limitToFromState=state) - # Compute the permissions/roles mapping for this state - permissionsMapping = {} - for permission, roles in state.getPermissions().iteritems(): - for plonePerm in self.getPlonePermissions(permission): - permissionsMapping[plonePerm] = [r.name for r in roles] - # Add 'Review portal content' to anyone; this is not a security - # problem because we limit the triggering of every transition - # individually. - allRoles = [r.name for r in self.generator.getAllUsedRoles()] - if 'Manager' not in allRoles: allRoles.append('Manager') - permissionsMapping['Review portal content'] = allRoles - res[stateName] = (tNames, permissionsMapping) - if not asDumpableCode: - return res - # We must create the "Python code" version of this dict - newRes = '{' - for stateName, stateInfo in res.iteritems(): - transitions = ','.join(['"%s"' % tn for tn in stateInfo[0]]) - # Compute permissions - permissions = '' - for perm, roles in stateInfo[1].iteritems(): - theRoles = ','.join(['"%s"' % r for r in roles]) - permissions += '"%s": [%s],' % (perm, theRoles) - newRes += '\n "%s": ([%s], {%s}),' % \ - (stateName, transitions, permissions) - return newRes + '}' - - def getTransitionsInfo(self, asDumpableCode=False): - '''Gets, in a dict, information for configuring transitions of the - workflow. If p_asDumpableCode is True, instead of returning a dict, - this method will return a string containing the dict that can be - dumped into a Python code file.''' - res = {} - for tName in self.getTransitionNames(): - res[tName] = self.getEndStateName(tName) - if not asDumpableCode: - return res - # We must create the "Python code" version of this dict - newRes = '{' - for transitionName, endStateName in res.iteritems(): - newRes += '\n "%s": "%s",' % (transitionName, endStateName) - return newRes + '}' - - def getManagedPermissions(self): - '''Returns the Plone permissions of all Appy permissions managed by this - workflow.''' - res = set() - res.add('Review portal content') - for state in self.getStates(): - for permission in state.permissions.iterkeys(): - for plonePerm in self.getPlonePermissions(permission): - res.add(plonePerm) - return res - - def getScripts(self): - res = '' - wfName = WorkflowDescriptor.getWorkflowName(self.klass) - for tName in self.getTransitionNames(): - scriptName = '%s_do%s%s' % (wfName, tName[0].upper(), tName[1:]) - res += 'def %s(self, stateChange, **kw): do("%s", ' \ - 'stateChange, logger)\n' % (scriptName, tName) - return res # ------------------------------------------------------------------------------ diff --git a/gen/plone25/generator.py b/gen/plone25/generator.py index 83de4d1..19b80c8 100644 --- a/gen/plone25/generator.py +++ b/gen/plone25/generator.py @@ -8,9 +8,9 @@ from appy.gen import * from appy.gen.po import PoMessage, PoFile, PoParser from appy.gen.generator import Generator as AbstractGenerator from appy.gen.utils import getClassName -from descriptors import ClassDescriptor, WorkflowDescriptor, \ - ToolClassDescriptor, UserClassDescriptor, \ - TranslationClassDescriptor +from appy.gen.descriptors import WorkflowDescriptor +from descriptors import ClassDescriptor, ToolClassDescriptor, \ + UserClassDescriptor, TranslationClassDescriptor from model import ModelClass, User, Tool, Translation # Common methods that need to be defined on every Archetype class -------------- @@ -38,7 +38,6 @@ class Generator(AbstractGenerator): AbstractGenerator.__init__(self, *args, **kwargs) # Set our own Descriptor classes self.descriptorClasses['class'] = ClassDescriptor - self.descriptorClasses['workflow'] = WorkflowDescriptor # Create our own Tool, User and Translation instances self.tool = ToolClassDescriptor(Tool, self) self.user = UserClassDescriptor(User, self) @@ -159,7 +158,6 @@ class Generator(AbstractGenerator): # Create basic files (config.py, Install.py, etc) self.generateTool() self.generateInit() - self.generateWorkflows() self.generateTests() if self.config.frontPage: self.generateFrontPage() @@ -368,33 +366,6 @@ class Generator(AbstractGenerator): catalogMap += "catalogMap['%s']['black'] = " \ "['portal_catalog']\n" % blackClass repls['catalogMap'] = catalogMap - # Compute workflows - workflows = '' - for classDescr in classesAll: - if hasattr(classDescr.klass, 'workflow'): - wfName = WorkflowDescriptor.getWorkflowName( - classDescr.klass.workflow) - workflows += '\n "%s":"%s",' % (classDescr.name, wfName) - repls['workflows'] = workflows - # Compute workflow instances initialisation - wfInit = '' - for workflowDescr in self.workflows: - k = workflowDescr.klass - className = '%s.%s' % (k.__module__, k.__name__) - wfInit += 'wf = %s()\n' % className - wfInit += 'wf._transitionsMapping = {}\n' - for transition in workflowDescr.getTransitions(): - tName = workflowDescr.getNameOf(transition) - tNames = workflowDescr.getTransitionNamesOf(tName, transition) - for trName in tNames: - wfInit += 'wf._transitionsMapping["%s"] = wf.%s\n' % \ - (trName, tName) - # We need a new attribute that stores states in order - wfInit += 'wf._states = []\n' - for stateName in workflowDescr.getStateNames(ordered=True): - wfInit += 'wf._states.append("%s")\n' % stateName - wfInit += 'workflowInstances[%s] = wf\n' % className - repls['workflowInstancesInit'] = wfInit # Compute the list of ordered attributes (forward and backward, # inherited included) for every Appy class. attributes = [] @@ -463,40 +434,6 @@ class Generator(AbstractGenerator): repls['totalNumberOfTests'] = self.totalNumberOfTests self.copyFile('__init__.py', repls) - def generateWorkflows(self): - '''Generates the file that contains one function by workflow. - Those functions are called by Plone for registering the workflows.''' - workflows = '' - for wfDescr in self.workflows: - # Compute state names & info, transition names & infos, managed - # permissions - stateNames=','.join(['"%s"' % sn for sn in wfDescr.getStateNames()]) - stateInfos = wfDescr.getStatesInfo(asDumpableCode=True) - transitionNames = ','.join(['"%s"' % tn for tn in \ - wfDescr.getTransitionNames()]) - transitionInfos = wfDescr.getTransitionsInfo(asDumpableCode=True) - managedPermissions = ','.join(['"%s"' % tn for tn in \ - wfDescr.getManagedPermissions()]) - wfName = WorkflowDescriptor.getWorkflowName(wfDescr.klass) - workflows += '%s\ndef create_%s(self, id):\n ' \ - 'stateNames = [%s]\n ' \ - 'stateInfos = %s\n ' \ - 'transitionNames = [%s]\n ' \ - 'transitionInfos = %s\n ' \ - 'managedPermissions = [%s]\n ' \ - 'return WorkflowCreator("%s", DCWorkflowDefinition, ' \ - 'stateNames, "%s", stateInfos, transitionNames, ' \ - 'transitionInfos, managedPermissions, PROJECTNAME, ' \ - 'ExternalMethod).run()\n' \ - 'addWorkflowFactory(create_%s,\n id="%s",\n ' \ - 'title="%s")\n\n' % (wfDescr.getScripts(), wfName, stateNames, - stateInfos, transitionNames, transitionInfos, - managedPermissions, wfName, wfDescr.getInitialStateName(), - wfName, wfName, wfName) - repls = self.repls.copy() - repls['workflows'] = workflows - self.copyFile('workflows.py', repls, destFolder='Extensions') - def generateWrapperProperty(self, name, type): '''Generates the getter for attribute p_name.''' res = ' def get_%s(self):\n ' % name @@ -519,18 +456,17 @@ class Generator(AbstractGenerator): * "custom" it includes descriptors for the config-related classes for which the user has created a sub-class.''' if not include: return self.classes - else: - res = self.classes[:] - configClasses = [self.tool, self.user, self.translation] - if include == 'all': - res += configClasses - elif include == 'allButTool': - res += configClasses[1:] - elif include == 'custom': - res += [c for c in configClasses if c.customized] - elif include == 'predefined': - res = configClasses - return res + res = self.classes[:] + configClasses = [self.tool, self.user, self.translation] + if include == 'all': + res += configClasses + elif include == 'allButTool': + res += configClasses[1:] + elif include == 'custom': + res += [c for c in configClasses if c.customized] + elif include == 'predefined': + res = configClasses + return res def getClassesInOrder(self, allClasses): '''When generating wrappers, classes mut be dumped in order (else, it @@ -793,44 +729,39 @@ class Generator(AbstractGenerator): self.copyFile('Class.py', repls, destName=fileName) def generateWorkflow(self, wfDescr): - '''This method does not generate the workflow definition, which is done - in self.generateWorkflows. This method just creates the i18n labels - related to the workflow described by p_wfDescr.''' + '''This method creates the i18n labels related to the workflow described + by p_wfDescr.''' k = wfDescr.klass print 'Generating %s.%s (gen-workflow)...' % (k.__module__, k.__name__) - # Identify Plone workflow name + # Identify workflow name wfName = WorkflowDescriptor.getWorkflowName(wfDescr.klass) - # Add i18n messages for states and transitions - for sName in wfDescr.getStateNames(): - poMsg = PoMessage('%s_%s' % (wfName, sName), '', sName) + # Add i18n messages for states + for name in dir(wfDescr.klass): + if not isinstance(getattr(wfDescr.klass, name), State): continue + poMsg = PoMessage('%s_%s' % (wfName, name), '', name) poMsg.produceNiceDefault() self.labels.append(poMsg) - for tName, tLabel in wfDescr.getTransitionNames(withLabels=True): - poMsg = PoMessage('%s_%s' % (wfName, tName), '', tLabel) + # Add i18n messages for transitions + for name in dir(wfDescr.klass): + transition = getattr(wfDescr.klass, name) + if not isinstance(transition, Transition): continue + poMsg = PoMessage('%s_%s' % (wfName, name), '', name) poMsg.produceNiceDefault() self.labels.append(poMsg) - for transition in wfDescr.getTransitions(): - # Get the Appy transition name - tName = wfDescr.getNameOf(transition) - # Get the names of the corresponding DC transition(s) - tNames = wfDescr.getTransitionNamesOf(tName, transition) if transition.confirm: # We need to generate a label for the message that will be shown # in the confirm popup. - for tn in tNames: - label = '%s_%s_confirm' % (wfName, tn) - poMsg = PoMessage(label, '', PoMessage.CONFIRM) - self.labels.append(poMsg) + label = '%s_%s_confirm' % (wfName, name) + poMsg = PoMessage(label, '', PoMessage.CONFIRM) + self.labels.append(poMsg) if transition.notify: # Appy will send a mail when this transition is triggered. - # So we need 2 i18n labels for every DC transition corresponding - # to this Appy transition: one for the mail subject and one for + # So we need 2 i18n labels: one for the mail subject and one for # the mail body. - for tn in tNames: - subjectLabel = '%s_%s_mail_subject' % (wfName, tn) - poMsg = PoMessage(subjectLabel, '', PoMessage.EMAIL_SUBJECT) - self.labels.append(poMsg) - bodyLabel = '%s_%s_mail_body' % (wfName, tn) - poMsg = PoMessage(bodyLabel, '', PoMessage.EMAIL_BODY) - self.labels.append(poMsg) + subjectLabel = '%s_%s_mail_subject' % (wfName, name) + poMsg = PoMessage(subjectLabel, '', PoMessage.EMAIL_SUBJECT) + self.labels.append(poMsg) + bodyLabel = '%s_%s_mail_body' % (wfName, name) + poMsg = PoMessage(bodyLabel, '', PoMessage.EMAIL_BODY) + self.labels.append(poMsg) # ------------------------------------------------------------------------------ diff --git a/gen/plone25/installer.py b/gen/plone25/installer.py index 3b6e973..8e5fb52 100644 --- a/gen/plone25/installer.py +++ b/gen/plone25/installer.py @@ -34,7 +34,6 @@ class PloneInstaller: self.catalogMap = cfg.catalogMap self.applicationRoles = cfg.applicationRoles # Roles defined in the app self.defaultAddRoles = cfg.defaultAddRoles - self.workflows = cfg.workflows self.appFrontPage = cfg.appFrontPage self.showPortlet = cfg.showPortlet self.languages = cfg.languages @@ -378,22 +377,6 @@ class PloneInstaller: site.portal_groups.setRolesForGroup(group, [role]) site.__ac_roles__ = tuple(data) - def installWorkflows(self): - '''Creates or updates the workflows defined in the application.''' - wfTool = self.ploneSite.portal_workflow - for contentType, workflowName in self.workflows.iteritems(): - # Register the workflow if needed - if workflowName not in wfTool.listWorkflows(): - wfMethod = self.config.ExternalMethod('temp', 'temp', - self.productName + '.workflows', 'create_%s' % workflowName) - workflow = wfMethod(self, workflowName) - wfTool._setObject(workflowName, workflow) - else: - self.appyTool.log('%s already in workflows.' % workflowName) - # Link the workflow to the current content type - wfTool.setChainForPortalTypes([contentType], workflowName) - return wfTool - def installStyleSheet(self): '''Registers In Plone the stylesheet linked to this application.''' cssName = self.productName + '.css' @@ -495,7 +478,6 @@ class PloneInstaller: self.installTool() self.installTranslations() self.installRolesAndGroups() - self.installWorkflows() self.installStyleSheet() self.managePortlets() self.manageIndexes() diff --git a/gen/plone25/mixins/__init__.py b/gen/plone25/mixins/__init__.py index 8be34b1..b47e4e7 100644 --- a/gen/plone25/mixins/__init__.py +++ b/gen/plone25/mixins/__init__.py @@ -5,9 +5,11 @@ # ------------------------------------------------------------------------------ import os, os.path, sys, types, mimetypes, urllib, cgi import appy.gen -from appy.gen import Type, String, Selection, Role, No +from appy.gen import Type, String, Selection, Role, No, WorkflowAnonymous, \ + Transition from appy.gen.utils import * from appy.gen.layout import Table, defaultPageLayouts +from appy.gen.descriptors import WorkflowDescriptor from appy.gen.plone25.descriptors import ClassDescriptor from appy.gen.plone25.utils import updateRolesForPermission,checkTransitionGuard @@ -300,6 +302,27 @@ class BaseMixin: else: logMethod = logger.info logMethod(msg) + def getState(self, name=True): + '''Returns state information about this object. If p_name is True, the + returned info is the state name. Else, it is the State instance.''' + if hasattr(self.aq_base, 'workflow_history'): + key = self.workflow_history.keys()[0] + stateName = self.workflow_history[key][-1]['review_state'] + if name: return stateName + else: return getattr(self.getWorkflow(), stateName) + else: + # No workflow information is available (yet) on this object. So + # return the workflow initial state. + wf = self.getWorkflow() + initStateName = 'active' + for elem in dir(wf): + attr = getattr(wf, elem) + if (attr.__class__.__name__ == 'State') and attr.initial: + initStateName = elem + break + if name: return initStateName + else: return getattr(wf, initStateName) + def rememberPreviousData(self): '''This method is called before updating an object and remembers, for every historized field, the previous value. Result is a dict @@ -327,11 +350,10 @@ class BaseMixin: else: changes[fieldName] = (changes[fieldName], appyType.labelId) # Create the event to record in the history - DateTime = self.getProductConfig().DateTime - state = self.portal_workflow.getInfoFor(self, 'review_state') + from DateTime import DateTime user = self.portal_membership.getAuthenticatedMember() event = {'action': '_datachange_', 'changes': changes, - 'review_state': state, 'actor': user.id, + 'review_state': self.getState(), 'actor': user.id, 'time': DateTime(), 'comments': ''} # Add the event to the history histKey = self.workflow_history.keys()[0] @@ -583,62 +605,48 @@ class BaseMixin: '''Returns information about the states that are related to p_phase. If p_currentOnly is True, we return the current state, even if not related to p_phase.''' - res = [] - dcWorkflow = self.getWorkflow(appy=False) - if not dcWorkflow: return res - currentState = self.portal_workflow.getInfoFor(self, 'review_state') + currentState = self.getState() if currentOnly: - return [StateDescr(currentState,'current').get()] - workflow = self.getWorkflow(appy=True) - if workflow: - stateStatus = 'done' - for stateName in workflow._states: - if stateName == currentState: - stateStatus = 'current' - elif stateStatus != 'done': - stateStatus = 'future' - state = getattr(workflow, stateName) - if (state.phase == phase) and \ - (self._appy_showState(workflow, state.show)): - res.append(StateDescr(stateName, stateStatus).get()) + return [StateDescr(currentState, 'current').get()] + res = [] + workflow = self.getWorkflow() + stateStatus = 'done' + for stateName in dir(workflow): + if getattr(workflow, stateName).__class__.__name__ != 'State': + continue + if stateName == currentState: + stateStatus = 'current' + elif stateStatus != 'done': + stateStatus = 'future' + state = getattr(workflow, stateName) + if (state.phase == phase) and \ + (self._appy_showState(workflow, state.show)): + res.append(StateDescr(stateName, stateStatus).get()) return res def getAppyTransitions(self, includeFake=True, includeNotShowable=False): - '''This method is similar to portal_workflow.getTransitionsFor, but: - * 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?)''' + '''This method returns info about transitions that one can trigger from + the user interface. + * if p_includeFake is True, it retrieves transitions that the user + can't trigger, but for which he needs to know for what reason he + can't trigger it; + * if p_includeNotShowable is True, it includes transitions for which + show=False. 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. + ''' res = [] - # Get some Plone stuff from the Plone-level config.py - TRIGGER_USER_ACTION = self.getProductConfig().TRIGGER_USER_ACTION - sm = self.getProductConfig().getSecurityManager - # Get the workflow definition for p_obj. - workflow = self.getWorkflow(appy=False) - if not workflow: return res - appyWorkflow = self.getWorkflow(appy=True) - # What is the current state for this object? - currentState = workflow._getWorkflowStateOf(self) - if not currentState: return res - # Analyse all the transitions that start from this state. - for transitionId in currentState.transitions: - transition = workflow.transitions.get(transitionId, None) - 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) + wf = self.getWorkflow() + currentState = self.getState(name=False) + # Loop on every transition + for name in dir(wf): + transition = getattr(wf, name) + if (transition.__class__.__name__ != 'Transition'): continue + # Filter transitions that do not have currentState as start state + if not transition.hasState(currentState, True): continue + # Check if the transition can be triggered + mayTrigger = transition.isTriggerable(self, wf) # Compute the condition that will lead to including or not this # transition if not includeFake: @@ -646,19 +654,15 @@ class BaseMixin: else: includeIt = mayTrigger or isinstance(mayTrigger, No) if not includeNotShowable: - includeIt = includeIt and appyTr.isShowable(appyWorkflow, self) + includeIt = includeIt and transition.isShowable(wf, 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') + label = self.getWorkflowLabel(name) + tInfo = {'name': name, 'title': self.translate(label), + 'confirm': '', 'may_trigger': True} + if transition.confirm: + cLabel = '%s_confirm' % label + tInfo['confirm'] = self.translate(cLabel, format='js') if not mayTrigger: tInfo['may_trigger'] = False tInfo['reason'] = mayTrigger.msg @@ -793,39 +797,32 @@ class BaseMixin: reverse = rq.get('reverse') == 'True' self.appy().sort(fieldName, sortKey=sortKey, reverse=reverse) - def getWorkflow(self, appy=True): - '''Returns the Appy workflow instance that is relevant for this - object. If p_appy is False, it returns the DC workflow.''' - res = None - if appy: - # Get the workflow class first - workflowClass = None - if self.wrapperClass: - appyClass = self.wrapperClass.__bases__[-1] - if hasattr(appyClass, 'workflow'): - workflowClass = appyClass.workflow - if workflowClass: - # Get the corresponding prototypical workflow instance - res = self.getProductConfig().workflowInstances[workflowClass] - else: - dcWorkflows = self.portal_workflow.getWorkflowsFor(self) - if dcWorkflows: - res = dcWorkflows[0] - return res + def notifyWorkflowCreated(self): + '''This method is called by Zope/CMF every time an object is created, + be it temp or not. The objective here is to initialise workflow- + related data on the object.''' + wf = self.getWorkflow() + # Get the initial workflow state + initialState = self.getState(name=False) + # Create a Transition instance representing the initial transition. + initialTransition = Transition((initialState, initialState)) + initialTransition.trigger('_init_', self, wf, '') + + def getWorkflow(self, name=False): + '''Returns the workflow applicable for p_self (or its name, if p_name + is True).''' + appyClass = self.wrapperClass.__bases__[-1] + if hasattr(appyClass, 'workflow'): wf = appyClass.workflow + else: wf = WorkflowAnonymous + if not name: return wf + return WorkflowDescriptor.getWorkflowName(wf) def getWorkflowLabel(self, stateName=None): - '''Gets the i18n label for the workflow current state. If no p_stateName - is given, workflow label is given for the current state.''' - res = '' - wf = self.getWorkflow(appy=False) - if wf: - res = stateName - if not res: - res = self.portal_workflow.getInfoFor(self, 'review_state') - appyWf = self.getWorkflow(appy=True) - if appyWf: - res = '%s_%s' % (wf.id, res) - return res + '''Gets the i18n label for p_stateName, or for the current object state + if p_stateName is not given. Note that if p_stateName is given, it + can also represent the name of a transition.''' + stateName = stateName or self.getState() + return '%s_%s' % (self.getWorkflow(name=True), stateName) def hasHistory(self): '''Has this object an history?''' @@ -848,39 +845,6 @@ class BaseMixin: return {'events': history[startNumber:startNumber+batchSize], 'totalNumber': len(history)} - def may(self, transitionName): - '''May the user execute transition named p_transitionName?''' - # Get the Appy workflow instance - workflow = self.getWorkflow() - res = False - if workflow: - # Get the corresponding Appy transition - transition = workflow._transitionsMapping[transitionName] - user = self.portal_membership.getAuthenticatedMember() - if isinstance(transition.condition, Role): - # It is a role. Transition may be triggered if the user has this - # role. - res = user.has_role(transition.condition.name, self) - elif type(transition.condition) == types.FunctionType: - res = transition.condition(workflow, self.appy()) - elif type(transition.condition) in (tuple, list): - # It is a list of roles and or functions. Transition may be - # triggered if user has at least one of those roles and if all - # functions return True. - hasRole = None - for roleOrFunction in transition.condition: - if isinstance(roleOrFunction, basestring): - if hasRole == None: - hasRole = False - if user.has_role(roleOrFunction, self): - hasRole = True - elif type(roleOrFunction) == types.FunctionType: - if not roleOrFunction(workflow, self.appy()): - return False - if hasRole != False: - res = True - return res - def mayNavigate(self): '''May the currently logged user see the navigation panel linked to this object?''' @@ -946,16 +910,28 @@ class BaseMixin: # the user. return self.goto(msg) - def onTriggerTransition(self): + def do(self, transitionName, comment='', doAction=True, doNotify=True, + doHistory=True, doSay=True): + '''Triggers transition named p_transitionName.''' + # Check that this transition exists. + wf = self.getWorkflow() + if not hasattr(wf, transitionName) or \ + getattr(wf, transitionName).__class__.__name__ != 'Transition': + raise 'Transition "%s" was not found.' % transitionName + # Is this transition triggerable? + transition = getattr(wf, transitionName) + if not transition.isTriggerable(self, wf): + raise 'Transition "%s" can\'t be triggered' % transitionName + # Trigger the transition + transition.trigger(transitionName, self, wf, comment, doAction=doAction, + doNotify=doNotify, doHistory=doHistory, doSay=doSay) + + def onDo(self): '''This method is called whenever a user wants to trigger a workflow transition on an object.''' rq = self.REQUEST - self.portal_workflow.doActionFor(self, rq['workflow_action'], - comment = rq.get('comment', '')) + self.do(rq['workflow_action'], comment=rq.get('comment', '')) self.reindexObject() - # Where to redirect the user back ? - # TODO (?): remove the "phase" param for redirecting the user to the - # next phase when relevant. return self.goto(self.getUrl(rq['HTTP_REFERER'])) def fieldValueSelected(self, fieldName, vocabValue, dbValue): diff --git a/gen/plone25/notifier.py b/gen/plone25/notifier.py index b205f18..f5e2990 100644 --- a/gen/plone25/notifier.py +++ b/gen/plone25/notifier.py @@ -34,10 +34,10 @@ SENDMAIL_ERROR = 'Error while sending mail: %s.' ENCODING_ERROR = 'Encoding error while sending mail: %s.' from appy.gen.utils import sequenceTypes -from appy.gen.plone25.descriptors import WorkflowDescriptor +from appy.gen.descriptors import WorkflowDescriptor import socket -def sendMail(obj, transition, transitionName, workflow, logger): +def sendMail(obj, transition, transitionName, workflow): '''Sends mail about p_transition that has been triggered on p_obj that is controlled by p_workflow.''' wfName = WorkflowDescriptor.getWorkflowName(workflow.__class__) @@ -94,12 +94,12 @@ def sendMail(obj, transition, transitionName, workflow, logger): recipient.encode(enc), fromAddress.encode(enc), mailSubject.encode(enc), mcc=cc, charset='utf-8') except socket.error, sg: - logger.warn(SENDMAIL_ERROR % str(sg)) + obj.log(SENDMAIL_ERROR % str(sg), type='warning') break except UnicodeDecodeError, ue: - logger.warn(ENCODING_ERROR % str(ue)) + obj.log(ENCODING_ERROR % str(ue), type='warning') break except Exception, e: - logger.warn(SENDMAIL_ERROR % str(e)) + obj.log(SENDMAIL_ERROR % str(e), type='warning') break # ------------------------------------------------------------------------------ diff --git a/gen/plone25/skin/page.pt b/gen/plone25/skin/page.pt index 42fb431..851a525 100644 --- a/gen/plone25/skin/page.pt +++ b/gen/plone25/skin/page.pt @@ -515,7 +515,7 @@ tal:condition="transitions">