[Checkins] SVN: hurry.workflow/ Initial import (moved from codespeak).
Martijn Faassen
faassen at startifact.com
Wed Jun 17 08:40:16 EDT 2009
Log message for revision 101107:
Initial import (moved from codespeak).
Changed:
A hurry.workflow/
A hurry.workflow/trunk/
A hurry.workflow/trunk/CHANGES.txt
A hurry.workflow/trunk/CREDITS.txt
A hurry.workflow/trunk/INSTALL.txt
A hurry.workflow/trunk/README.txt
A hurry.workflow/trunk/buildout.cfg
A hurry.workflow/trunk/setup.py
A hurry.workflow/trunk/src/
A hurry.workflow/trunk/src/hurry/
A hurry.workflow/trunk/src/hurry/__init__.py
A hurry.workflow/trunk/src/hurry/workflow/
A hurry.workflow/trunk/src/hurry/workflow/__init__.py
A hurry.workflow/trunk/src/hurry/workflow/configure.zcml
A hurry.workflow/trunk/src/hurry/workflow/interfaces.py
A hurry.workflow/trunk/src/hurry/workflow/tests.py
A hurry.workflow/trunk/src/hurry/workflow/workflow.py
A hurry.workflow/trunk/src/hurry/workflow/workflow.txt
-=-
Added: hurry.workflow/trunk/CHANGES.txt
===================================================================
--- hurry.workflow/trunk/CHANGES.txt (rev 0)
+++ hurry.workflow/trunk/CHANGES.txt 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,54 @@
+hurry.workflow changes
+**********************
+
+0.10 (unreleased)
+=================
+
+* ...
+
+0.9.2.1 (2007-08-15)
+====================
+
+Bug fixes
+---------
+
+* Oops, the patches in 0.9.2 were not actually applied. Fixed them
+ now.
+
+0.9.2 (2007-08-15)
+==================
+
+Bug fixes
+---------
+
+* zope.security changes broke imports in hurry.workflow.
+
+* localUtility directive is now deprecated, so don't use it anymore.
+
+0.9.1 (2006-09-22)
+==================
+
+Feature changes
+---------------
+
+* first cheesehop release.
+
+0.9 (2006-06-15)
+================
+
+Feature changes
+---------------
+
+* separate out from hurry package into hurry.workflow
+
+* eggification work
+
+* Zope 3.3 compatibility work
+
+0.8 (2006-05-01)
+================
+
+Feature changes
+---------------
+
+Initial public release.
Added: hurry.workflow/trunk/CREDITS.txt
===================================================================
--- hurry.workflow/trunk/CREDITS.txt (rev 0)
+++ hurry.workflow/trunk/CREDITS.txt 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,11 @@
+Credits
+-------
+
+* Martijn Faassen - initial and main developer
+
+* Jan-Wijbrand Kolman - suggestions and feedback
+
+* Tobias Rodäbel - compatibility fixes
+
+The hurry.workflow library for the Zope Toolkit was originally
+developed at Infrae (http://www.infrae.com).
Added: hurry.workflow/trunk/INSTALL.txt
===================================================================
--- hurry.workflow/trunk/INSTALL.txt (rev 0)
+++ hurry.workflow/trunk/INSTALL.txt 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,9 @@
+Installation
+------------
+
+
+hurry.workflow needs Zope 3.3.
+
+Make sure hurry.workflow's src directory is on the Python path
+somehow, and then copy hurry.workflow-configure.zcml into your Zope's
+`etc/package-includes` directory.
Added: hurry.workflow/trunk/README.txt
===================================================================
--- hurry.workflow/trunk/README.txt (rev 0)
+++ hurry.workflow/trunk/README.txt 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,5 @@
+**************
+hurry.workflow
+**************
+
+A simple but quite nifty workflow system for Zope 3.
Added: hurry.workflow/trunk/buildout.cfg
===================================================================
--- hurry.workflow/trunk/buildout.cfg (rev 0)
+++ hurry.workflow/trunk/buildout.cfg 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,9 @@
+[buildout]
+develop = .
+parts = test
+newest = false
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = hurry.workflow [test]
+defaults = ['--tests-pattern', '^f?tests$', '-v']
Added: hurry.workflow/trunk/setup.py
===================================================================
--- hurry.workflow/trunk/setup.py (rev 0)
+++ hurry.workflow/trunk/setup.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,59 @@
+import os
+from setuptools import setup, find_packages
+
+def read(*rnames):
+ return open(os.path.join(os.path.dirname(__file__), *rnames)).read()
+
+long_description = (
+ read('README.txt')
+ + '\n' +
+ read('CHANGES.txt')
+ + '\n' +
+ 'Detailed Documentation\n'
+ '**********************\n'
+ + '\n' +
+ read('src', 'hurry', 'workflow', 'workflow.txt')
+ + '\n' +
+ 'Download\n'
+ '********\n'
+ )
+
+setup(
+ name="hurry.workflow",
+ version="0.10dev",
+ packages=find_packages('src'),
+
+ package_dir= {'':'src'},
+
+ namespace_packages=['hurry'],
+ package_data = {
+ '': ['*.txt', '*.zcml'],
+ },
+
+ zip_safe=False,
+ author='Martijn Faassen (at Infrae)',
+ author_email='faassen at startifact.com',
+ description="""\
+hurry.workflow is a simple workflow system. It can be used to
+implement stateful multi-version workflows for Zope Toolkit applications.
+""",
+ long_description=long_description,
+ license='ZPL 2.1',
+ keywords="zope zope3",
+ classifiers = ['Framework :: Zope3'],
+ extras_require = dict(
+ test = ['zope.testing'],
+ ),
+ install_requires=[
+ 'setuptools',
+ 'zope.interface',
+ 'zope.component',
+ # this is an indirect dependency through zope.annotation, but
+ # this at the time of working didn't yet declare this
+ # dependency itself
+ 'ZODB3',
+ 'zope.event',
+ 'zope.security',
+ 'zope.annotation',
+ 'zope.lifecycleevent'],
+ )
Added: hurry.workflow/trunk/src/hurry/__init__.py
===================================================================
--- hurry.workflow/trunk/src/hurry/__init__.py (rev 0)
+++ hurry.workflow/trunk/src/hurry/__init__.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,7 @@
+# this is a namespace package
+try:
+ import pkg_resources
+ pkg_resources.declare_namespace(__name__)
+except ImportError:
+ import pkgutil
+ __path__ = pkgutil.extend_path(__path__, __name__)
Added: hurry.workflow/trunk/src/hurry/workflow/__init__.py
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/__init__.py (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/__init__.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1 @@
+# this is a package
Added: hurry.workflow/trunk/src/hurry/workflow/configure.zcml
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/configure.zcml (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/configure.zcml 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,12 @@
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ >
+
+ <class class=".workflow.Workflow">
+ <require
+ interface=".interfaces.IWorkflow"
+ permission="zope.Public"
+ />
+ </class>
+
+</configure>
Added: hurry.workflow/trunk/src/hurry/workflow/interfaces.py
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/interfaces.py (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/interfaces.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,196 @@
+from zope.interface import Interface, Attribute
+from zope.component.interfaces import IObjectEvent
+
+MANUAL = 0
+AUTOMATIC = 1
+SYSTEM = 2
+
+class InvalidTransitionError(Exception):
+ pass
+
+class NoTransitionAvailableError(InvalidTransitionError):
+ pass
+
+class AmbiguousTransitionError(InvalidTransitionError):
+ pass
+
+class ConditionFailedError(Exception):
+ pass
+
+class IWorkflow(Interface):
+ """Defines workflow in the form of transition objects.
+
+ Defined as a utility.
+ """
+
+ def refresh(transitions):
+ """Refresh workflow completely with new transitions.
+ """
+
+ def getTransitions(source):
+ """Get all transitions from source.
+ """
+
+ def getTransition(source, transition_id):
+ """Get transition with transition_id given source state.
+
+ If the transition is invalid from this source state,
+ an InvalidTransitionError is raised.
+ """
+
+ def getTransitionById(transition_id):
+ """Get transition with transition_id.
+ """
+
+class IWorkflowState(Interface):
+ """Store state on workflowed objects.
+
+ Defined as an adapter.
+ """
+
+ def setState(state):
+ """Set workflow state for this object.
+ """
+
+ def setId(id):
+ """Set workflow version id for this object.
+
+ This is used to mark all versions of an object with the
+ same id.
+ """
+
+ def getState():
+ """Return workflow state of this object.
+ """
+
+ def getId():
+ """Get workflow version id for this object.
+
+ This is used to mark all versions of an object with the same id.
+ """
+
+class IWorkflowInfo(Interface):
+ """Get workflow info about workflowed object, and drive workflow.
+
+ Defined as an adapter.
+ """
+
+ def setInitialState(state, comment=None):
+ """Set initial state for the context object.
+
+ Will also set a unique id for this new workflow.
+
+ Fires a transition event.
+ """
+
+ def fireTransition(transition_id, comment=None, side_effect=None,
+ check_security=True):
+ """Fire a transition for the context object.
+
+ There's an optional comment parameter that contains some
+ opaque object that offers a comment about the transition.
+ This is useful for manual transitions where users can motivate
+ their actions.
+
+ There's also an optional side effect parameter which should
+ be a callable which receives the object undergoing the transition
+ as the parameter. This could do an editing action of the newly
+ transitioned workflow object before an actual transition event is
+ fired.
+
+ If check_security is set to False, security is not checked
+ and an application can fire a transition no matter what the
+ user's permission is.
+ """
+
+ def fireTransitionToward(state, comment=None, side_effect=None,
+ check_security=True):
+ """Fire transition toward state.
+
+ Looks up a manual transition that will get to the indicated
+ state.
+
+ If no such transition is possible, NoTransitionAvailableError will
+ be raised.
+
+ If more than one manual transitions are possible,
+ AmbiguousTransitionError will be raised.
+ """
+
+ def fireTransitionForVersions(state, transition_id):
+ """Fire a transition for all versions in a state.
+ """
+
+ def fireAutomatic():
+ """Fire automatic transitions if possible by condition.
+ """
+
+ def hasVersion(state):
+ """Return true if a version exists in state.
+ """
+
+ def getManualTransitionIds():
+ """Returns list of valid manual transitions.
+
+ These transitions have to have a condition that's True.
+ """
+
+ def getManualTransitionIdsToward(state):
+ """Returns list of manual transitions towards state.
+ """
+
+ def getAutomaticTransitionIds():
+ """Returns list of possible automatic transitions.
+
+ Condition is not checked.
+ """
+
+ def hasAutomaticTransitions():
+ """Return true if there are possible automatic outgoing transitions.
+
+ Condition is not checked.
+ """
+
+class IReadWorkflowVersions(Interface):
+
+ def getVersions(state, id):
+ """Get all versions of object known for this id and state.
+ """
+
+ def getVersionsWithAutomaticTransitions():
+ """Get all versions that have outgoing transitions that are automatic.
+ """
+
+ def createVersionId():
+ """Return new unique version id.
+ """
+
+ def hasVersion(id, state):
+ """Return true if a version exists with the specific workflow state.
+ """
+
+ def hasVersionId(id):
+ """Return True if version id is already in use.
+ """
+
+class IWriteWorkflowVersions(Interface):
+ def fireAutomatic():
+ """Fire all automatic transitions in the workflow (for all versions).
+ """
+
+class IWorkflowVersions(IReadWorkflowVersions, IWriteWorkflowVersions):
+ """Interface to get information about versions of content in workflow.
+
+ This can be implemented on top of the Zope catalog, for instance.
+
+ Defined as a utility
+ """
+
+class IWorkflowTransitionEvent(IObjectEvent):
+ source = Attribute('Original state or None if initial state')
+ destination = Attribute('New state')
+ transition = Attribute('Transition that was fired or None if initial state')
+ comment = Attribute('Comment that went with state transition')
+
+class IWorkflowVersionTransitionEvent(IWorkflowTransitionEvent):
+ old_object = Attribute('Old version of object')
Added: hurry.workflow/trunk/src/hurry/workflow/tests.py
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/tests.py (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/tests.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,78 @@
+import unittest
+
+from zope.testing import doctest
+from zope import component
+from zope.component import testing
+from zope.annotation import interfaces as annotation_interfaces
+from zope.annotation import attribute
+from hurry.workflow import interfaces, workflow
+
+class WorkflowVersions(workflow.WorkflowVersions):
+ """Simplistic implementation that keeps track of versions.
+
+ A real implementation would use something like the catalog.
+ """
+ def __init__(self):
+ self.versions = []
+
+ def addVersion(self, obj):
+ self.versions.append(obj)
+
+ def getVersions(self, state, id):
+ result = []
+ for version in self.versions:
+ state_adapter = interfaces.IWorkflowState(version)
+ if state_adapter.getId() == id and state_adapter.getState() == state:
+ result.append(version)
+ return result
+
+ def getVersionsWithAutomaticTransitions(self):
+ result = []
+ for version in self.versions:
+ if interfaces.IWorkflowInfo(version).hasAutomaticTransitions():
+ result.append(version)
+ return result
+
+ def hasVersion(self, state, id):
+ return bool(self.getVersions(state, id))
+
+ def hasVersionId(self, id):
+ result = []
+ for version in self.versions:
+ state_adapter = interfaces.IWorkflowState(version)
+ if state_adapter.getId() == id:
+ return True
+ return False
+
+ def clear(self):
+ self.versions = []
+
+def workflowSetUp(doctest):
+ testing.setUp(doctest)
+ component.provideAdapter(
+ workflow.WorkflowState,
+ (annotation_interfaces.IAnnotatable,),
+ interfaces.IWorkflowState)
+ component.provideAdapter(
+ workflow.WorkflowInfo,
+ (annotation_interfaces.IAnnotatable,),
+ interfaces.IWorkflowInfo)
+ component.provideAdapter(
+ attribute.AttributeAnnotations,
+ (annotation_interfaces.IAttributeAnnotatable,),
+ annotation_interfaces.IAnnotations)
+ component.provideUtility(
+ WorkflowVersions(),
+ interfaces.IWorkflowVersions)
+
+def test_suite():
+ return unittest.TestSuite((
+ doctest.DocFileSuite(
+ 'workflow.txt',
+ setUp=workflowSetUp, tearDown=testing.tearDown,
+ ),
+ ))
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
+
Added: hurry.workflow/trunk/src/hurry/workflow/workflow.py
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/workflow.py (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/workflow.py 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,317 @@
+import random, sys
+
+from zope.interface import implements
+from zope.event import notify
+from zope.security.checker import CheckerPublic
+from zope.security.interfaces import NoInteraction, Unauthorized
+from zope.security.management import getInteraction
+from zope import component
+
+from zope.annotation.interfaces import IAnnotations
+from zope.lifecycleevent import ObjectModifiedEvent
+from zope.component.interfaces import ObjectEvent
+
+from hurry.workflow import interfaces
+from hurry.workflow.interfaces import MANUAL, AUTOMATIC, SYSTEM
+from hurry.workflow.interfaces import\
+ IWorkflow, IWorkflowState, IWorkflowInfo, IWorkflowVersions
+from hurry.workflow.interfaces import\
+ InvalidTransitionError, ConditionFailedError
+
+def NullCondition(wf, context):
+ return True
+
+def NullAction(wf, context):
+ pass
+
+# XXX this is needed to make the tests pass in the absence of
+# interactions..
+def nullCheckPermission(permission, principal_id):
+ return True
+
+class Transition(object):
+
+ def __init__(self, transition_id, title, source, destination,
+ condition=NullCondition,
+ action=NullAction,
+ trigger=MANUAL,
+ permission=CheckerPublic,
+ order=0,
+ **user_data):
+ self.transition_id = transition_id
+ self.title = title
+ self.source = source
+ self.destination = destination
+ self.condition = condition
+ self.action = action
+ self.trigger = trigger
+ self.permission = permission
+ self.order = order
+ self.user_data = user_data
+
+ def __cmp__(self, other):
+ return cmp(self.order, other.order)
+
+# in the past this subclassed from zope.container.Contained and
+# persistent.Persistent.
+# to reduce dependencies these base classes have been removed.
+# You can choose to create a subclass in your own code that
+# mixes these in if you need persistent workflow
+class Workflow(object):
+ implements(IWorkflow)
+
+ def __init__(self, transitions):
+ self.refresh(transitions)
+
+ def _register(self, transition):
+ transitions = self._sources.setdefault(transition.source, {})
+ transitions[transition.transition_id] = transition
+ self._id_transitions[transition.transition_id] = transition
+
+ def refresh(self, transitions):
+ self._sources = {}
+ self._id_transitions = {}
+ for transition in transitions:
+ self._register(transition)
+ self._p_changed = True
+
+ def getTransitions(self, source):
+ try:
+ return self._sources[source].values()
+ except KeyError:
+ return []
+
+ def getTransition(self, source, transition_id):
+ transition = self._id_transitions[transition_id]
+ if transition.source != source:
+ raise InvalidTransitionError
+ return transition
+
+ def getTransitionById(self, transition_id):
+ return self._id_transitions[transition_id]
+
+class WorkflowState(object):
+ implements(IWorkflowState)
+
+ def __init__(self, context):
+ # XXX okay, I'm tired of it not being able to set annotations, so
+ # we'll do this. Ugh.
+ from zope.security.proxy import removeSecurityProxy
+ self.context = removeSecurityProxy(context)
+
+ def initialize(self):
+ wf_versions = component.getUtility(IWorkflowVersions)
+ self.setId(wf_versions.createVersionId())
+
+ def setState(self, state):
+ if state != self.getState():
+ IAnnotations(self.context)[
+ 'hurry.workflow.state'] = state
+
+ def setId(self, id):
+ # XXX catalog should be informed (or should it?)
+ IAnnotations(self.context)['hurry.workflow.id'] = id
+
+ def getState(self):
+ try:
+ return IAnnotations(self.context)['hurry.workflow.state']
+ except KeyError:
+ return None
+
+ def getId(self):
+ try:
+ return IAnnotations(self.context)['hurry.workflow.id']
+ except KeyError:
+ return None
+
+class WorkflowInfo(object):
+ implements(IWorkflowInfo)
+
+ def __init__(self, context):
+ self.context = context
+
+ def fireTransition(self, transition_id, comment=None, side_effect=None,
+ check_security=True):
+ state = IWorkflowState(self.context)
+ wf = component.getUtility(IWorkflow)
+ # this raises InvalidTransitionError if id is invalid for current state
+ transition = wf.getTransition(state.getState(), transition_id)
+ # check whether we may execute this workflow transition
+ try:
+ interaction = getInteraction()
+ except NoInteraction:
+ checkPermission = nullCheckPermission
+ else:
+ if check_security:
+ checkPermission = interaction.checkPermission
+ else:
+ checkPermission = nullCheckPermission
+ if not checkPermission(
+ transition.permission, self.context):
+ raise Unauthorized(self.context,
+ 'transition: %s' % transition_id,
+ transition.permission)
+ # now make sure transition can still work in this context
+ if not transition.condition(self, self.context):
+ raise ConditionFailedError
+ # perform action, return any result as new version
+ result = transition.action(self, self.context)
+ if result is not None:
+ if transition.source is None:
+ IWorkflowState(result).initialize()
+ # stamp it with version
+ state = IWorkflowState(result)
+ state.setId(IWorkflowState(self.context).getId())
+ # execute any side effect:
+ if side_effect is not None:
+ side_effect(result)
+ event = WorkflowVersionTransitionEvent(result, self.context,
+ transition.source,
+ transition.destination,
+ transition, comment)
+ else:
+ if transition.source is None:
+ IWorkflowState(self.context).initialize()
+ # execute any side effect
+ if side_effect is not None:
+ side_effect(self.context)
+ event = WorkflowTransitionEvent(self.context,
+ transition.source,
+ transition.destination,
+ transition, comment)
+ # change state of context or new object
+ state.setState(transition.destination)
+ notify(event)
+ # send modified event for original or new object
+ if result is None:
+ notify(ObjectModifiedEvent(self.context))
+ else:
+ notify(ObjectModifiedEvent(result))
+ return result
+
+ def fireTransitionToward(self, state, comment=None, side_effect=None,
+ check_security=True):
+ transition_ids = self.getFireableTransitionIdsToward(state)
+ if not transition_ids:
+ raise interfaces.NoTransitionAvailableError
+ if len(transition_ids) != 1:
+ raise interfaces.AmbiguousTransitionError
+ return self.fireTransition(transition_ids[0],
+ comment, side_effect, check_security)
+
+ def fireTransitionForVersions(self, state, transition_id):
+ id = IWorkflowState(self.context).getId()
+ wf_versions = component.getUtility(IWorkflowVersions)
+ for version in wf_versions.getVersions(state, id):
+ if version is self.context:
+ continue
+ IWorkflowInfo(version).fireTransition(transition_id)
+
+ def fireAutomatic(self):
+ for transition_id in self.getAutomaticTransitionIds():
+ try:
+ self.fireTransition(transition_id)
+ except ConditionFailedError:
+ # if condition failed, that's fine, then we weren't
+ # ready to fire yet
+ pass
+ else:
+ # if we actually managed to fire a transition,
+ # we're done with this one now.
+ return
+
+ def hasVersion(self, state):
+ wf_versions = component.getUtility(IWorkflowVersions)
+ id = IWorkflowState(self.context).getId()
+ return wf_versions.hasVersion(state, id)
+
+ def getManualTransitionIds(self):
+ try:
+ checkPermission = getInteraction().checkPermission
+ except NoInteraction:
+ checkPermission = nullCheckPermission
+ return [transition.transition_id for transition in
+ sorted(self._getTransitions(MANUAL)) if
+ transition.condition(self, self.context) and
+ checkPermission(transition.permission, self.context)]
+
+ def getSystemTransitionIds(self):
+ # ignore permission checks
+ return [transition.transition_id for transition in
+ sorted(self._getTransitions(SYSTEM)) if
+ transition.condition(self, self.context)]
+
+ def getFireableTransitionIds(self):
+ return self.getManualTransitionIds() + self.getSystemTransitionIds()
+
+ def getFireableTransitionIdsToward(self, state):
+ wf = component.getUtility(IWorkflow)
+ result = []
+ for transition_id in self.getFireableTransitionIds():
+ transition = wf.getTransitionById(transition_id)
+ if transition.destination == state:
+ result.append(transition_id)
+ return result
+
+ def getAutomaticTransitionIds(self):
+ return [transition.transition_id for transition in
+ self._getTransitions(AUTOMATIC)]
+
+ def hasAutomaticTransitions(self):
+ # XXX could be faster
+ return bool(self.getAutomaticTransitionIds())
+
+ def _getTransitions(self, trigger):
+ # retrieve all possible transitions from workflow utility
+ wf = component.getUtility(IWorkflow)
+ transitions = wf.getTransitions(
+ IWorkflowState(self.context).getState())
+ # now filter these transitions to retrieve all possible
+ # transitions in this context, and return their ids
+ return [transition for transition in transitions if
+ transition.trigger == trigger]
+
+class WorkflowVersions(object):
+ implements(IWorkflowVersions)
+
+ def getVersions(self, state, id):
+ raise NotImplementedError
+
+ def getVersionsWithAutomaticTransitions(self):
+ raise NotImplementedError
+
+ def createVersionId(self):
+ while True:
+ id = random.randrange(sys.maxint)
+ if not self.hasVersionId(id):
+ return id
+ assert False, "Shouldn't ever reach here"
+
+ def hasVersion(self, state, id):
+ raise NotImplementedError
+
+ def hasVersionId(self, id):
+ raise NotImplementedError
+
+ def fireAutomatic(self):
+ for version in self.getVersionsWithAutomaticTransitions():
+ IWorkflowInfo(version).fireAutomatic()
+
+class WorkflowTransitionEvent(ObjectEvent):
+ implements(interfaces.IWorkflowTransitionEvent)
+
+ def __init__(self, object, source, destination, transition, comment):
+ super(WorkflowTransitionEvent, self).__init__(object)
+ self.source = source
+ self.destination = destination
+ self.transition = transition
+ self.comment = comment
+
+class WorkflowVersionTransitionEvent(WorkflowTransitionEvent):
+ implements(interfaces.IWorkflowVersionTransitionEvent)
+
+ def __init__(self, object, old_object, source, destination,
+ transition, comment):
+ super(WorkflowVersionTransitionEvent, self).__init__(
+ object, source, destination, transition, comment)
+ self.old_object = old_object
Added: hurry.workflow/trunk/src/hurry/workflow/workflow.txt
===================================================================
--- hurry.workflow/trunk/src/hurry/workflow/workflow.txt (rev 0)
+++ hurry.workflow/trunk/src/hurry/workflow/workflow.txt 2009-06-17 12:40:16 UTC (rev 101107)
@@ -0,0 +1,783 @@
+Hurry Workflow
+==============
+
+The hurry workflow system is a "roll my own because I'm in a hurry"
+framework.
+
+Basic workflow
+--------------
+
+Let's first make a content object that can go into a workflow::
+
+ >>> from zope.interface import implements, Attribute
+
+ >>> from zope.annotation.interfaces import IAttributeAnnotatable
+ >>> class IDocument(IAttributeAnnotatable):
+ ... title = Attribute('Title')
+ >>> class Document(object):
+ ... implements(IDocument)
+ ... def __init__(self, title):
+ ... self.title = title
+
+As you can see, such a content object must provide IAnnotatable, as
+this is used to store the workflow state. The system uses the
+IWorkflowState adapter to get and set an object's workflow state::
+
+ >>> from hurry.workflow import interfaces
+ >>> document = Document('Foo')
+ >>> state = interfaces.IWorkflowState(document)
+ >>> print state.getState()
+ None
+
+The state can be set directly for an object using the IWorkflowState
+adapter as well::
+
+ >>> state.setState('foo')
+ >>> state.getState()
+ 'foo'
+
+But let's set it back to None again, so we can start again in a
+pristine state for this document::
+
+ >>> state.setState(None)
+
+It's not recommended use setState() do this ourselves, though: usually
+we'll let the workflow system take care of state transitions and the
+setting of the initial state.
+
+Now let's define a simple workflow transition from 'a' to 'b'. It
+needs a condition which must return True before the transition is
+allowed to occur::
+
+ >>> def NullCondition(wf, context):
+ ... return True
+
+and an action that takes place when the transition is taken::
+
+ >>> def NullAction(wf, context):
+ ... pass
+
+Now let's construct a transition::
+
+ >>> from hurry.workflow import workflow
+ >>> transition = workflow.Transition(
+ ... transition_id='a_to_b',
+ ... title='A to B',
+ ... source='a',
+ ... destination='b',
+ ... condition=NullCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.MANUAL)
+
+The transition trigger is either MANUAL, AUTOMATIC or SYSTEM. MANUAL
+indicates user action is needed to fire the transition. AUTOMATIC
+transitions fire automatically. SYSTEM is a workflow transition
+directly fired by the system, and not directly by the user.
+
+We also will introduce an initial transition, that moves an object
+into the workflow (for instance just after it is created)::
+
+ >>> init_transition = workflow.Transition(
+ ... transition_id='to_a',
+ ... title='Create A',
+ ... source=None,
+ ... destination='a')
+
+And a final transition, when the object moves out of the workflow again
+(for instance just before it is deleted)::
+
+ >>> final_transition = workflow.Transition(
+ ... transition_id='finalize',
+ ... title='Delete',
+ ... source='b',
+ ... destination=None)
+
+Now let's put the transitions in an workflow utility::
+
+ >>> wf = workflow.Workflow([transition, init_transition, final_transition])
+ >>> from zope import component
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+
+Workflow transitions cause events to be fired; we will put in a simple
+handler so we can check whether things were successfully fired::
+
+ >>> events = []
+ >>> def transition_handler(event):
+ ... events.append(event)
+ >>> component.provideHandler(
+ ... transition_handler,
+ ... [interfaces.IWorkflowTransitionEvent])
+
+To get what transitions to other states are possible from an object,
+as well as to fire transitions and set initial state, we use the
+IWorkflowInfo adapter::
+
+ >>> info = interfaces.IWorkflowInfo(document)
+
+We'll initialize the workflow by firing the 'to_a' transition::
+
+ >>> info.fireTransition('to_a')
+
+This should've fired an event::
+
+ >>> events[-1].transition.transition_id
+ 'to_a'
+ >>> events[-1].source is None
+ True
+ >>> events[-1].destination
+ 'a'
+
+There's only a single transition defined to workflow state 'b'::
+
+ >>> info.getManualTransitionIds()
+ ['a_to_b']
+
+We can also get this by asking which manual (or system) transition
+exists that brings us to the desired workflow state::
+
+ >>> info.getFireableTransitionIdsToward('b')
+ ['a_to_b']
+
+Since this is a manually triggered transition, we can fire this
+transition::
+
+ >>> info.fireTransition('a_to_b')
+
+The workflow state should now be 'b'::
+
+ >>> state.getState()
+ 'b'
+
+We check that the event indeed got fired::
+
+ >>> events[-1].transition.transition_id
+ 'a_to_b'
+ >>> events[-1].source
+ 'a'
+ >>> events[-1].destination
+ 'b'
+
+We will also try fireTransitionToward here, so we sneak back the
+workflow to state 'a' again and try that::
+
+ >>> state.setState('a')
+
+Try going through a transition we cannot reach first::
+
+ >>> info.fireTransitionToward('c')
+ Traceback (most recent call last):
+ ...
+ NoTransitionAvailableError
+
+Now go to 'b' again::
+
+ >>> info.fireTransitionToward('b')
+ >>> state.getState()
+ 'b'
+
+Finally, before forgetting about our document, we finalize the workflow::
+
+ >>> info.fireTransition('finalize')
+ >>> state.getState() is None
+ True
+
+And we have another event that was fired::
+
+ >>> events[-1].transition.transition_id
+ 'finalize'
+ >>> events[-1].source
+ 'b'
+ >>> events[-1].destination is None
+ True
+
+Multi-version workflow
+----------------------
+
+Now let's go for a more complicated scenario where have multiple
+versions of a document. At any one time a document can have an
+UNPUBLISHED version and a PUBLISHED version. There can also be a
+CLOSED version and any number of ARCHIVED versions::
+
+ >>> UNPUBLISHED = 'unpublished'
+ >>> PUBLISHED = 'published'
+ >>> CLOSED = 'closed'
+ >>> ARCHIVED = 'archived'
+
+Let's start with a simple initial transition::
+
+ >>> init_transition = workflow.Transition(
+ ... transition_id='init',
+ ... title='Initialize',
+ ... source=None,
+ ... destination=UNPUBLISHED)
+
+When the unpublished version is published, any previously published
+version is made to be the CLOSED version. To accomplish this secondary
+state transition, we'll use the system's built-in versioning ability
+with the 'fireTransitionsForVersions' method, which can be used to
+fire transitions of other versions of the document::
+
+ >>> def PublishAction(wf, context):
+ ... wf.fireTransitionForVersions(PUBLISHED, 'close')
+
+Now let's build the transition::
+
+ >>> publish_transition = workflow.Transition(
+ ... transition_id='publish',
+ ... title='Publish',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=NullCondition,
+ ... action=PublishAction,
+ ... trigger=interfaces.MANUAL,
+ ... order=1)
+
+Next, we'll define a transition from PUBLISHED to CLOSED, which means
+we want to archive whatever was closed before::
+
+ >>> def CloseAction(wf, context):
+ ... wf.fireTransitionForVersions(CLOSED, 'archive')
+ >>> close_transition = workflow.Transition(
+ ... transition_id='close',
+ ... title='Close',
+ ... source=PUBLISHED,
+ ... destination=CLOSED,
+ ... condition=NullCondition,
+ ... action=CloseAction,
+ ... trigger=interfaces.MANUAL,
+ ... order=2)
+
+Note that CloseAction will also be executed automatically whenever
+state is transitioned from PUBLISHED to CLOSED using
+fireTransitionsForVersions. This means that publishing a document
+results in the previously closed document being archived.
+
+If there is a PUBLISHED but no UNPUBLISHED version, we can make a new
+copy of the PUBLISHED version and make that the UNPUBLISHED version::
+
+ >>> def CanCopyCondition(wf, context):
+ ... return not wf.hasVersion(UNPUBLISHED)
+
+Since we are actually creating a new content object, the action should
+return the newly created object with the new state::
+
+ >>> def CopyAction(wf, context):
+ ... return Document('copy of %s' % context.title)
+
+ >>> copy_transition = workflow.Transition(
+ ... transition_id='copy',
+ ... title='Copy',
+ ... source=PUBLISHED,
+ ... destination=UNPUBLISHED,
+ ... condition=CanCopyCondition,
+ ... action=CopyAction,
+ ... trigger=interfaces.MANUAL,
+ ... order=3)
+
+A very similar transition applies to the closed version. If we have
+no UNPUBLISHED version and no PUBLISHED version, we can make a new copy
+from the CLOSED version::
+
+ >>> def CanCopyCondition(wf, context):
+ ... return (not wf.hasVersion(UNPUBLISHED) and
+ ... not wf.hasVersion(PUBLISHED))
+
+ >>> copy_closed_transition = workflow.Transition(
+ ... transition_id='copy_closed',
+ ... title='Copy',
+ ... source=CLOSED,
+ ... destination=UNPUBLISHED,
+ ... condition=CanCopyCondition,
+ ... action=CopyAction,
+ ... trigger=interfaces.MANUAL,
+ ... order=4)
+
+Finally let's build the archiving transition::
+
+ >>> archive_transition = workflow.Transition(
+ ... transition_id='archive',
+ ... title='Archive',
+ ... source=CLOSED,
+ ... destination=ARCHIVED,
+ ... condition=NullCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.MANUAL,
+ ... order=5)
+
+Now let's build and provide the workflow utility::
+
+ >>> wf = workflow.Workflow([init_transition,
+ ... publish_transition, close_transition,
+ ... copy_transition, copy_closed_transition,
+ ... archive_transition])
+
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+
+Let's get the workflow_versions utility which we can use to track
+versions and come up with a new unique id::
+
+ >>> workflow_versions = component.getUtility(interfaces.IWorkflowVersions)
+
+And let's start with a document::
+
+ >>> document = Document('bar')
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+We need the document id to compare later when we create a new version::
+
+ >>> state = interfaces.IWorkflowState(document)
+ >>> document_id = state.getId()
+
+Let's add it to the workflow versions container so we can find it. Note
+that we're using a private API here; this could be implemented as adding
+it to a folder or any other way, as long as getVersions() works later::
+
+ >>> workflow_versions.addVersion(document) # private API
+
+Also clear out previously recorded events::
+
+ >>> del events[:]
+
+We can publish it::
+
+ >>> info.getManualTransitionIds()
+ ['publish']
+
+So let's do that::
+
+ >>> info.fireTransition('publish')
+ >>> state.getState()
+ 'published'
+
+The last event should be the 'publish' transition::
+
+ >>> events[-1].transition.transition_id
+ 'publish'
+
+And now we can either close or create a new copy of it. Note that the
+names are sorted using the order of the transitions::
+
+ >>> info.getManualTransitionIds()
+ ['close', 'copy']
+
+Let's close it::
+
+ >>> info.fireTransition('close')
+ >>> state.getState()
+ 'closed'
+
+We're going to create a new copy for editing now::
+
+ >>> info.getManualTransitionIds()
+ ['copy_closed', 'archive']
+ >>> document2 = info.fireTransition('copy_closed')
+ >>> workflow_versions.addVersion(document2) # private API to track it
+ >>> document2.title
+ 'copy of bar'
+ >>> state = interfaces.IWorkflowState(document2)
+ >>> state.getState()
+ 'unpublished'
+ >>> state.getId() == document_id
+ True
+
+The original version is still there in its original state::
+
+ >>> interfaces.IWorkflowState(document).getState()
+ 'closed'
+
+Let's also check the last event in some detail::
+
+ >>> event = events[-1]
+ >>> event.transition.transition_id
+ 'copy_closed'
+ >>> event.old_object == document
+ True
+ >>> event.object == document2
+ True
+
+Now we are going to publish the new version::
+
+ >>> info = interfaces.IWorkflowInfo(document2)
+ >>> info.getManualTransitionIds()
+ ['publish']
+ >>> info.fireTransition('publish')
+ >>> interfaces.IWorkflowState(document2).getState()
+ 'published'
+
+The original is still closed::
+
+ >>> interfaces.IWorkflowState(document).getState()
+ 'closed'
+
+Now let's publish another copy after this::
+
+ >>> document3 = info.fireTransition('copy')
+ >>> workflow_versions.addVersion(document3)
+ >>> interfaces.IWorkflowInfo(document3).fireTransition('publish')
+
+This copy is now published::
+
+ >>> interfaces.IWorkflowState(document3).getState()
+ 'published'
+
+And the previously published version is now closed::
+
+ >>> interfaces.IWorkflowState(document2).getState()
+ 'closed'
+
+Note that due to the condition, it's not possible to copy from the
+closed version, as there is a published version still remaining::
+
+ >>> interfaces.IWorkflowInfo(document2).getManualTransitionIds()
+ ['archive']
+
+Meanwhile, the original version, previously closed, is now archived::
+
+ >>> interfaces.IWorkflowState(document).getState()
+ 'archived'
+
+Automatic transitions
+---------------------
+
+Now let's try a workflow transition that is automatic and time-based.
+We'll set up a very simple workflow between 'unpublished' and
+'published', and have the 'published' transition be time-based.
+
+To simulate time, we have moments::
+
+ >>> time_moment = 0
+
+We will only publish if time_moment is greater than 3::
+
+ >>> def TimeCondition(wf, context):
+ ... return time_moment > 3
+
+Set up the transition using this condition; note that this one is
+automatic, i.e. it doesn't have to be triggered by humans::
+
+ >>> publish_transition = workflow.Transition(
+ ... transition_id='publish',
+ ... title='Publish',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=TimeCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.AUTOMATIC)
+
+Set up the workflow using this transition, and reusing the
+init transition we defined before::
+
+ >>> wf = workflow.Workflow([init_transition, publish_transition])
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+
+Clear out all versions; this is an private API we just use for
+demonstration purposes::
+
+ >>> workflow_versions.clear()
+
+Now create a document::
+
+ >>> document = Document('bar')
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+Private again; do this with the catalog or any way you prefer in your
+own code::
+
+ >>> workflow_versions.addVersion(document)
+
+Since this transition is automatic, we should see it like this::
+
+ >>> interfaces.IWorkflowInfo(document).getAutomaticTransitionIds()
+ ['publish']
+
+Now fire let's any automatic transitions::
+
+ >>> workflow_versions.fireAutomatic()
+
+Nothing should have happened as we are still at time moment 0::
+
+ >>> state = interfaces.IWorkflowState(document)
+ >>> state.getState()
+ 'unpublished'
+
+We change the time moment past 3::
+
+ >>> time_moment = 4
+
+Now fire any automatic transitions again::
+
+ >>> workflow_versions.fireAutomatic()
+
+The transition has fired, so the state will be 'published'::
+
+ >>> state.getState()
+ 'published'
+
+System transitions
+------------------
+
+Let's try system transitions now. This transition shouldn't show up
+as manual nor as automatic::
+
+ >>> publish_transition = workflow.Transition(
+ ... transition_id='publish',
+ ... title='Publish',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... trigger=interfaces.SYSTEM)
+
+Set up the workflow using this transition, and reusing the
+init transition we defined before::
+
+ >>> wf = workflow.Workflow([init_transition, publish_transition])
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+
+Clear out all versions; this is an private API we just use for
+demonstration purposes::
+
+ >>> workflow_versions.clear()
+
+Now create a document::
+
+ >>> document = Document('bar')
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+Private again; do this with the catalog or any way you prefer in your
+own code::
+
+ >>> workflow_versions.addVersion(document)
+
+We should see it as a system transition::
+
+ >>> info.getSystemTransitionIds()
+ ['publish']
+
+but not as automatic nor manual::
+
+ >>> info.getAutomaticTransitionIds()
+ []
+ >>> info.getManualTransitionIds()
+ []
+
+This transition can be fired::
+
+ >>> info.fireTransition('publish')
+ >>> interfaces.IWorkflowState(document).getState()
+ 'published'
+
+Multiple transitions
+--------------------
+
+It's possible to have multiple transitions from the source state to
+the target state, for instance an automatic and a manual one.
+
+Let's set up a workflow with two manual transitions and a single
+automatic transitions between two states::
+
+ >>> publish_1_transition = workflow.Transition(
+ ... transition_id='publish 1',
+ ... title='Publish 1',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=NullCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.MANUAL)
+
+ >>> publish_2_transition = workflow.Transition(
+ ... transition_id='publish 2',
+ ... title='Publish 2',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=NullCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.MANUAL)
+
+ >>> publish_auto_transition = workflow.Transition(
+ ... transition_id='publish auto',
+ ... title='Publish Auto',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=TimeCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.AUTOMATIC)
+
+Clear out all versions; this is an private API we just use for
+demonstration purposes::
+
+ >>> workflow_versions.clear()
+
+Since we're using the time condition again, let's make sure
+time is at 0 again so that the publish_auto_transition doesn't fire::
+
+ >>> time_moment = 0
+
+Now set up the workflow using these transitions, plus our
+init_transition::
+
+ >>> wf = workflow.Workflow([init_transition,
+ ... publish_1_transition, publish_2_transition,
+ ... publish_auto_transition])
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+
+Now create a document::
+
+ >>> document = Document('bar')
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+We should have two manual transitions::
+
+ >>> sorted(interfaces.IWorkflowInfo(document).getManualTransitionIds())
+ ['publish 1', 'publish 2']
+
+And a single automatic transition::
+
+ >>> interfaces.IWorkflowInfo(document).getAutomaticTransitionIds()
+ ['publish auto']
+
+Protecting transitions with permissions
+---------------------------------------
+
+Permissions can be (and should be) protected with a permission, so
+that not everybody can execute them.
+
+Let's set up a workflow with a permission that has a permission::
+
+ >>> publish_transition = workflow.Transition(
+ ... transition_id='publish',
+ ... title='Publish',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=NullCondition,
+ ... action=NullAction,
+ ... trigger=interfaces.MANUAL,
+ ... permission="zope.ManageContent")
+
+Quickly set up the workflow state again for a document::
+
+ >>> workflow_versions.clear()
+ >>> wf = workflow.Workflow([init_transition, publish_transition])
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+ >>> document = Document('bar')
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+Let's set up the security context::
+
+ >>> from zope.security.interfaces import Unauthorized
+ >>> from zope.security.management import newInteraction, endInteraction
+ >>> class Principal:
+ ... def __init__(self, id):
+ ... self.id = id
+ ... self.groups = []
+ >>> class Participation:
+ ... interaction = None
+ ... def __init__(self, principal):
+ ... self.principal = principal
+ >>> endInteraction() # XXX argh, apparently one is already active?
+ >>> newInteraction(Participation(Principal('bob')))
+
+We shouldn't see this permission appear in our list of possible transitions,
+as we do not have access::
+
+ >>> info.getManualTransitionIds()
+ []
+
+Now let's try firing the transition. It should fail with Unauthorized::
+
+ >>> try:
+ ... info.fireTransition('publish')
+ ... except Unauthorized:
+ ... print "Got unauthorized"
+ Got unauthorized
+
+The system user is however allowed to do it::
+
+ >>> from zope.security.management import system_user
+ >>> endInteraction()
+ >>> newInteraction(Participation(system_user))
+ >>> info.fireTransition('publish')
+
+And this goes off without a problem.
+
+There is also a special way to make it happen by passing check_security is
+False to fireTransition::
+
+ >>> endInteraction()
+ >>> newInteraction(Participation(Principal('bob')))
+ >>> interfaces.IWorkflowState(document).setState(UNPUBLISHED)
+ >>> info.fireTransition('publish', check_security=False)
+
+Side effects during transitions
+-------------------------------
+
+Sometimes we would like something to get executed *before* the
+WorkflowTransitionEvent is fired, but after a (potential) new version
+of the object has been created. If an object is edited during the
+same request as a workflow transition, the editing should take place
+after a potential new version has been created, otherwise the old, not
+the new, version will be edited.
+
+If something like a history logger hooks into IWorkflowTransitionEvent
+however, it would get information about the new copy *before* the
+editing took place. To allow an editing to take place between the
+creation of the new copy and the firing of the event, a side effect
+function can be passed along when a transition is fired.
+
+The sequence of execution then is:
+
+* firing of transition itself, creating a new version
+
+* executing the side effect function on the new version
+
+* firing the IWorkflowTransitionEvent
+
+Let's set up a very simple workflow:
+
+ >>> foo_transition = workflow.Transition(
+ ... transition_id='foo',
+ ... title='Foo',
+ ... source=UNPUBLISHED,
+ ... destination=PUBLISHED,
+ ... condition=NullCondition,
+ ... action=CopyAction,
+ ... trigger=interfaces.MANUAL)
+
+Quickly set up the workflow state again for a document::
+
+ >>> workflow_versions.clear()
+ >>> wf = workflow.Workflow([init_transition, foo_transition])
+ >>> component.provideUtility(wf, interfaces.IWorkflow)
+ >>> document = Document('bar')
+ >>> events = []
+ >>> info = interfaces.IWorkflowInfo(document)
+ >>> info.fireTransition('init')
+
+Now let's set up a side effect::
+
+ >>> def side_effect(context):
+ ... context.title = context.title + '!'
+
+Now fire the transition, with a side effect::
+
+ >>> new_version = info.fireTransition('foo', side_effect=side_effect)
+
+The title of the new version should now have a ! at the end::
+
+ >>> new_version.title[-1] == '!'
+ True
+
+But the old version doesn't::
+
+ >>> document.title[-1] == '!'
+ False
+
+The events list we set up before should contain two events::
+
+ >>> len(events)
+ 2
+ >>> events[1].object.title[-1] == '!'
+ True
More information about the Checkins
mailing list