[z3-checkins] r17417 - in z3/hurry/trunk: . src src/hurry
src/hurry/file src/hurry/file/browser src/hurry/query
src/hurry/workflow
faassen at codespeak.net
faassen at codespeak.net
Fri Sep 9 18:04:11 CEST 2005
Author: faassen
Date: Fri Sep 9 18:04:08 2005
New Revision: 17417
Added:
z3/hurry/trunk/CREDITS.txt
z3/hurry/trunk/INSTALL.txt
z3/hurry/trunk/LICENSE.txt
z3/hurry/trunk/README.txt
z3/hurry/trunk/VERSION.txt
z3/hurry/trunk/hurry-configure.zcml
z3/hurry/trunk/src/
z3/hurry/trunk/src/hurry/
z3/hurry/trunk/src/hurry/__init__.py
z3/hurry/trunk/src/hurry/configure.zcml
z3/hurry/trunk/src/hurry/file/
z3/hurry/trunk/src/hurry/file/README.txt
z3/hurry/trunk/src/hurry/file/__init__.py
z3/hurry/trunk/src/hurry/file/browser/
z3/hurry/trunk/src/hurry/file/browser/__init__.py
z3/hurry/trunk/src/hurry/file/browser/file.txt
z3/hurry/trunk/src/hurry/file/browser/tests.py
z3/hurry/trunk/src/hurry/file/browser/widget.py
z3/hurry/trunk/src/hurry/file/configure.zcml
z3/hurry/trunk/src/hurry/file/file.py
z3/hurry/trunk/src/hurry/file/interfaces.py
z3/hurry/trunk/src/hurry/file/schema.py
z3/hurry/trunk/src/hurry/query/
z3/hurry/trunk/src/hurry/query/__init__.py
z3/hurry/trunk/src/hurry/query/configure.zcml
z3/hurry/trunk/src/hurry/query/interfaces.py
z3/hurry/trunk/src/hurry/query/query.py
z3/hurry/trunk/src/hurry/query/query.txt
z3/hurry/trunk/src/hurry/query/set.py
z3/hurry/trunk/src/hurry/query/tests.py
z3/hurry/trunk/src/hurry/workflow/
z3/hurry/trunk/src/hurry/workflow/__init__.py
z3/hurry/trunk/src/hurry/workflow/configure.zcml
z3/hurry/trunk/src/hurry/workflow/interfaces.py
z3/hurry/trunk/src/hurry/workflow/tests.py
z3/hurry/trunk/src/hurry/workflow/workflow.py
z3/hurry/trunk/src/hurry/workflow/workflow.txt
z3/hurry/trunk/zc.catalog-configure.zcml
Log:
Initial import of hurry library.
Added: z3/hurry/trunk/CREDITS.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/CREDITS.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,8 @@
+Credits
+-------
+
+Martijn Faassen - initial and main developer
+Jan-Wijbrand Kolman - suggestions and feedback
+
+The hurry library for Zope 3 was originally developed at Infrae
+(http://www.infrae.com).
Added: z3/hurry/trunk/INSTALL.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/INSTALL.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,9 @@
+Installation
+------------
+
+Hurry needs Zope 3.1. Install it in a Zope install's lib/python
+directory.
+
+hurry.query also has support for zc.catalog SetIndexes in
+hurry.query.set. zc.catalog can be found here in the Zope 3
+SVN repository, in Sandbox/zc/catalog/
Added: z3/hurry/trunk/LICENSE.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/LICENSE.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,29 @@
+Copyright (c) 2005 Infrae. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ 3. Neither the name of Infrae nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Added: z3/hurry/trunk/README.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/README.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,17 @@
+Packages that could be generic and eventually might, in changed form,
+end up in the Zope 3 core or Zope 3 ECM, but that we (at Infrae) built
+in isolation for now, as we're in a hurry to use them.
+
+It includes:
+
+hurry.query - higher level query system built on top of the Zope 3
+ catalog. Some inspiration came from Dieter Maurer's
+ AdvancedQuery. See src/hurry/query/query.txt for
+ documentation.
+
+hurry.workflow - a simple but quite nifty workflow system. See
+ src/hurry/workflow/workflow.txt for documentation.
+
+hurry.file - advanced file widget which tries its best to behave like
+ other widgets. See the doctest in
+ src/hurry/file/browser/file.txt for some documentation.
Added: z3/hurry/trunk/VERSION.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/VERSION.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1 @@
+0.8
Added: z3/hurry/trunk/hurry-configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/hurry-configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,3 @@
+<!-- install this into your Zope 3 instance's etc/package-includes
+ directory -->
+<include package="hurry"/>
Added: z3/hurry/trunk/src/hurry/__init__.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/__init__.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1 @@
+# this is a package
Added: z3/hurry/trunk/src/hurry/configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,11 @@
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ >
+
+ <include package=".workflow"/>
+
+ <include package=".file"/>
+
+ <include package=".query" />
+
+</configure>
Added: z3/hurry/trunk/src/hurry/file/README.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/README.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,3 @@
+File widgets that behave like text widgets.
+
+For more information, see browser/file.txt
Added: z3/hurry/trunk/src/hurry/file/__init__.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/__init__.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,2 @@
+# this is a package
+from file import HurryFile
Added: z3/hurry/trunk/src/hurry/file/browser/__init__.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/browser/__init__.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,2 @@
+from widget import DownloadWidget, SessionFileWidget, EncodingFileWidget,\
+ EncodingFileUploadDownloadWidget, SessionFileUploadDownloadWidget
Added: z3/hurry/trunk/src/hurry/file/browser/file.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/browser/file.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,176 @@
+Hurry files
+===========
+
+This is an infrastructure to create a file widget that behaves as much
+as possible like a normal text widget.
+
+In order to do this, we have a special way to store file data along with
+its filename::
+
+ >>> from hurry.file import HurryFile
+ >>> some_file = HurryFile('foo.txt', 'the contents')
+ >>> some_file.filename
+ 'foo.txt'
+ >>> some_file.data
+ 'the contents'
+
+We can provide a download widget. In this case, there's nothing
+to download::
+
+ >>> from hurry.file.browser import DownloadWidget
+ >>> from hurry.file.schema import File
+ >>> from zope.publisher.browser import TestRequest
+ >>> field = File(__name__='foo', title=u'Foo')
+ >>> field = field.bind(None)
+ >>> request = TestRequest()
+ >>> widget = DownloadWidget(field, request)
+ >>> widget()
+ u'<div>Download not available</div>'
+
+Even if there were data in the request, there'd be nothing to download::
+
+ >>> from zope.publisher.browser import FileUpload
+ >>> request = TestRequest(form={'field.foo': FileUpload(some_file)})
+ >>> widget = DownloadWidget(field, request)
+ >>> widget()
+ u'<div>Download not available</div>'
+
+Now set a value::
+
+ >>> widget.setRenderedValue(some_file)
+ >>> widget()
+ u'<a href="foo.txt">foo.txt</a>'
+
+Now on to an edit widget. First the case in an add form with no
+data already available, and no data in request::
+
+ >>> from hurry.file.browser import EncodingFileWidget
+ >>> field = File(__name__='foo', title=u'Foo', required=False)
+ >>> field = field.bind(None)
+ >>> request = TestRequest()
+ >>> widget = EncodingFileWidget(field, request)
+
+ >>> def normalize(s):
+ ... return '\n '.join(filter(None, s.split(' ')))
+
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+
+Now let's try a situation where data is available in the request, but
+it's an empty string for the file::
+
+ >>> request = TestRequest(form={'field.foo': u''})
+ >>> widget = EncodingFileWidget(field, request)
+
+ >>> def normalize(s):
+ ... return '\n '.join(filter(None, s.split(' ')))
+
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+
+Now let's render again when there's already available data. What should show
+up is an extra, hidden field which contains the file_id::
+
+ >>> widget.setRenderedValue(some_file)
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+ (foo.txt)<input
+ class="hiddenType"
+ id="field.foo.file_id"
+ name="field.foo.file_id"
+ type="hidden"
+ value="Zm9vLnR4dAp0aGUgY29udGVudHM="
+ />
+
+Now let's render again, this time with file data available in the request
+instead. The same should happen::
+
+ >>> request = TestRequest(form={'field.foo': FileUpload(some_file)})
+ >>> widget = EncodingFileWidget(field, request)
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+ (foo.txt)<input
+ class="hiddenType"
+ id="field.foo.file_id"
+ name="field.foo.file_id"
+ type="hidden"
+ value="Zm9vLnR4dAp0aGUgY29udGVudHM="
+ />
+
+Now let's render again, this time not with file data available in the
+request, but an id. Again, we should see the same::
+
+ >>> request = TestRequest(form={'field.foo.file_id':
+ ... 'Zm9vLnR4dAp0aGUgY29udGVudHM='})
+ >>> widget = EncodingFileWidget(field, request)
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+ (foo.txt)<input
+ class="hiddenType"
+ id="field.foo.file_id"
+ name="field.foo.file_id"
+ type="hidden"
+ value="Zm9vLnR4dAp0aGUgY29udGVudHM="
+ />
+
+If there is both file data and an id, something else happens. First, let's
+prepare some new file::
+
+ >>> another_file = HurryFile('bar.txt', 'bar contents')
+
+We happen to know, due to the implementation of EncodingFileWidget,
+that the file_id is going to be "YmFyLnR4dApiYXIgY29udGVudHM=". Let's
+make a request with the original id, but a new file upload::
+
+ >>> request = TestRequest(form={'field.foo': FileUpload(another_file),
+ ... 'field.foo.file_id':
+ ... 'Zm9vLnR4dAp0aGUgY29udGVudHM='})
+
+We expect the new file to be the one that's uploaded::
+
+ >>> widget = EncodingFileWidget(field, request)
+ >>> print normalize(widget())
+ <input
+ class="fileType"
+ id="field.foo"
+ name="field.foo"
+ size="20"
+ type="file"
+ />
+ (bar.txt)<input
+ class="hiddenType"
+ id="field.foo.file_id"
+ name="field.foo.file_id"
+ type="hidden"
+ value="YmFyLnR4dApiYXIgY29udGVudHM="
+ />
Added: z3/hurry/trunk/src/hurry/file/browser/tests.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/browser/tests.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,19 @@
+import unittest
+
+from zope.testing import doctest
+from zope.app.testing import placelesssetup
+
+def workflowSetUp(doctest):
+ placelesssetup.setUp()
+
+def test_suite():
+ return unittest.TestSuite((
+ doctest.DocFileSuite(
+ 'file.txt',
+ setUp=workflowSetUp, tearDown=placelesssetup.tearDown,
+ ),
+ ))
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
+
Added: z3/hurry/trunk/src/hurry/file/browser/widget.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/browser/widget.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,202 @@
+import random, sys
+from StringIO import StringIO
+
+from zope.interface import implements
+from zope.publisher.browser import FileUpload
+
+from zope.app.form.browser import DisplayWidget
+from zope.app.form.interfaces import IDisplayWidget
+from zope.app.form.browser.widget import renderElement
+from zope.app.form.interfaces import ConversionError
+from zope.app.form.browser.textwidgets import escape
+from zope.app.session.interfaces import ISession
+from zope.app.form.browser import TextWidget
+
+from hurry.file.file import HurryFile
+
+class DownloadWidget(DisplayWidget):
+ """Display widget for file download.
+ """
+ implements(IDisplayWidget)
+
+ required = False
+
+ def __call__(self):
+ if self._renderedValueSet():
+ value = self._data
+ else:
+ value = self.context.default
+ if value == self.context.missing_value:
+ return renderElement(
+ u'div',
+ contents=u'Download not available')
+ filename = escape(value.filename)
+ return renderElement(
+ u'a',
+ href=filename,
+ contents=filename)
+
+class FakeFieldStorage:
+ def __init__(self, filename, data):
+ self.filename = filename
+ self.file = StringIO(data)
+ self.headers = {}
+
+class FileWidgetBase(TextWidget):
+ type = 'file'
+ _missing = None
+
+ def __call__(self):
+ value = self._getFormValue()
+ if value:
+ file_id = self._setFile(value)
+ else:
+ file_id = None
+
+ displayMaxWidth = self.displayMaxWidth or 0
+ if displayMaxWidth > 0:
+ result = renderElement(
+ self.tag,
+ type=self.type,
+ name=self.name,
+ id=self.name,
+ cssClass=self.cssClass,
+ size=self.displayWidth,
+ maxlength=displayMaxWidth,
+ extra=self.extra)
+ else:
+ result = renderElement(
+ self.tag,
+ type=self.type,
+ name=self.name,
+ id=self.name,
+ cssClass=self.cssClass,
+ size=self.displayWidth,
+ extra=self.extra)
+
+ # if there was data in the input, pass along the data id
+ if file_id is not None:
+ if value:
+ result += ' (%s)' % value.filename
+ result += renderElement(
+ 'input',
+ type='hidden',
+ name=self.name + '.file_id',
+ id=self.name + '.file_id',
+ value=file_id,
+ )
+ return result
+
+ def hasInput(self):
+ return (self.name in self.request.form or
+ self.name + '.file_id' in self.request.form)
+
+ def _getFormInput(self):
+ return (self.request.get(self.name),
+ self.request.get(self.name + '.file_id'))
+
+ def _toFormValue(self, value):
+ if value == self.context.missing_value:
+ return self._missing
+ return FileUpload(FakeFieldStorage(value.filename, value.data))
+
+ def _toFieldValue(self, (input, file_id)):
+ # we got no file upload input
+ if not input:
+ # if we got a file_id, then retrieve file and return it
+ if file_id:
+ return self._retrieveFile(file_id)
+ # no file upload input nor file id, so return missing value
+ return self.context.missing_value
+ # read in file from input
+ try:
+ seek = input.seek
+ read = input.read
+ except AttributeError, e:
+ raise ConversionError('Form input is not a file object', e)
+
+ seek(0)
+ data = read()
+
+ if data:
+ return HurryFile(input.filename, data)
+ else:
+ return self.context.missing_value
+
+ def _setFile(self, file):
+ """Store away uploaded file (FileUpload object).
+
+ Returns file_id identifying file.
+ """
+ # if there was no file input and there was a file_id already in the
+ # input, reuse this for next request
+ if not self.request.get(self.name):
+ file_id = self.request.get(self.name + '.file_id')
+ if file_id is not None:
+ return file_id
+ # otherwise, stuff filedata away in session, making a new file_id
+ if file == self.context.missing_value:
+ return None
+ return self._storeFile(file)
+
+ def _storeFile(self, file_upload):
+ """Store a file_upload away. Return unique file id.
+ """
+ raise NotImplementedError
+
+ def _retrieveFile(self, file_id):
+ """Retrieve a file. This returns a HurryFile, *not* a FileUpload.
+ """
+ raise NotImplementedError
+
+class EncodingFileWidget(FileWidgetBase):
+ """Stuff actual file data away in form, encoded.
+ """
+ def _storeFile(self, file_upload):
+ data = '%s\n%s' % (file_upload.filename, file_upload.read())
+ return data.encode('base64')[:-1]
+
+ def _retrieveFile(self, file_id):
+ data = file_id.decode('base64')
+ filename, filedata = data.split('\n', 1)
+ return HurryFile(filename, filedata)
+
+class SessionFileWidget(FileWidgetBase):
+ """Stuff file in session.
+ """
+ def _storeFile(self, file_upload):
+ """Store a file away. Return unique file id.
+ """
+ session = ISession(self.request)
+ while True:
+ file_id = random.randrange(sys.maxint)
+ session_id = 'session_file_widget.%s' % file_id
+ session_data = session[session_id]
+ if not session_data.has_key('data'):
+ break
+ session_data['data'] = {'filename': file_upload.filename,
+ 'data': file_upload.read()}
+ return file_id
+
+ def _retrieveFile(self, file_id):
+ session = ISession(self.request)
+ session_data = session['session_file_widget.%s' % file_id]['data']
+ return HurryFile(session_data['filename'], session_data['data'])
+
+class EncodingFileUploadDownloadWidget(EncodingFileWidget, DownloadWidget):
+ def __call__(self):
+ # first render the normal upload information
+ result = EncodingFileWidget.__call__(self)
+ # now show the download link
+ result += renderElement('br')
+ result += DownloadWidget.__call__(self)
+ return result
+
+class SessionFileUploadDownloadWidget(SessionFileWidget, DownloadWidget):
+ def __call__(self):
+ # first render the normal upload information
+ result = SessionFileWidget.__call__(self)
+ # now show the download link
+ result += renderElement('br')
+ result += DownloadWidget.__call__(self)
+ return result
Added: z3/hurry/trunk/src/hurry/file/configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,8 @@
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ >
+
+<!-- the widget is not yet the default widget for FileUpload; use
+ formlib's custom_widget system to use it. This may change -->
+
+</configure>
Added: z3/hurry/trunk/src/hurry/file/file.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/file.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,32 @@
+from StringIO import StringIO
+from persistent import Persistent
+from zope.interface import implements
+from hurry.file import interfaces
+
+class HurryFile(Persistent):
+ implements(interfaces.IHurryFile)
+
+ def __init__(self, filename, data):
+ self.filename = filename
+ self.data = data
+ self.headers = {}
+
+ def _get_file(self):
+ return StringIO(self.data)
+
+ file = property(_get_file)
+
+ def __eq__(self, other):
+ try:
+ return (self.filename == other.filename and
+ self.data == other.data)
+ except AttributeError:
+ return False
+
+ def __neq__(self, other):
+ try:
+ return (self.filename != other.filename or
+ self.data != other.data)
+ except AttributeError:
+ return True
+
Added: z3/hurry/trunk/src/hurry/file/interfaces.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/interfaces.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,12 @@
+from zope.interface import Interface, Attribute
+from zope.schema.interfaces import IField
+from zope.schema import TextLine, Bytes
+
+class IFile(IField):
+ u"""File field."""
+
+class IHurryFile(Interface):
+ filename = TextLine(title=u'Filename of file')
+ data = Bytes(title=u'Data in file')
+ file = Attribute('File-like object with data')
+ headers = Attribute('Headers associated with file')
Added: z3/hurry/trunk/src/hurry/file/schema.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/file/schema.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,12 @@
+from zope.interface import implements
+from zope.schema import Field
+
+from hurry.file.interfaces import IFile
+from hurry.file.file import HurryFile
+
+class File(Field):
+ __doc__ = IFile.__doc__
+
+ implements(IFile)
+
+ _type = HurryFile
Added: z3/hurry/trunk/src/hurry/query/__init__.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/__init__.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1 @@
+from query import And, Or, Eq, NotEq, Between, In, Ge, Le, Text
Added: z3/hurry/trunk/src/hurry/query/configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,9 @@
+<configure
+ xmlns="http://namespaces.zope.org/zope">
+
+ <utility
+ provides=".interfaces.IQuery"
+ factory=".query.Query"
+ />
+
+</configure>
Added: z3/hurry/trunk/src/hurry/query/interfaces.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/interfaces.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,9 @@
+from zope.interface import Interface
+
+class IQuery(Interface):
+ def searchResults(query):
+ """Query indexes.
+
+ Argument is a query composed of terms.
+ """
+
Added: z3/hurry/trunk/src/hurry/query/query.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/query.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,198 @@
+from zope.interface import implements
+
+from zope.app import zapi
+from zope.app.intid.interfaces import IIntIds
+from zope.app.catalog.catalog import ResultSet
+from zope.app.catalog.field import IFieldIndex
+from zope.app.catalog.text import ITextIndex
+from zope.app.catalog.interfaces import ICatalog
+
+from BTrees.IFBTree import weightedIntersection, union, difference, IFBTree
+
+from hurry.query import interfaces
+
+# XXX look into using multiunion for performance?
+
+class Query(object):
+ implements(interfaces.IQuery)
+
+ def searchResults(self, query):
+ results = query.apply()
+ if results is not None:
+ uidutil = zapi.getUtility(IIntIds)
+ results = ResultSet(results, uidutil)
+ return results
+
+class Term(object):
+ def __and__(self, other):
+ return And(self, other)
+
+ def __rand__(self, other):
+ return And(other, self)
+
+ def __or__(self, other):
+ return Or(self, other)
+
+ def __ror__(self, other):
+ return Or(other, self)
+
+ def __invert__(self):
+ return Not(self)
+
+class And(Term):
+ def __init__(self, *terms):
+ self.terms = terms
+
+ def apply(self):
+ results = []
+ for term in self.terms:
+ r = term.apply()
+ if not r:
+ # empty results
+ return r
+ results.append((len(r), r))
+
+ if not results:
+ # no applicable terms at all
+ # XXX should this be possible?
+ return IFBTree()
+
+ results.sort()
+
+ _, result = results.pop(0)
+ for _, r in results:
+ _, result = weightedIntersection(result, r)
+ return result
+
+class Or(Term):
+ def __init__(self, *terms):
+ self.terms = terms
+
+ def apply(self):
+ results = []
+ for term in self.terms:
+ r = term.apply()
+ # empty results
+ if not r:
+ continue
+ results.append(r)
+
+ if not results:
+ # no applicable terms at all
+ # XXX should this be possible?
+ return IFBTree()
+
+ result = results.pop(0)
+ for r in results:
+ result = union(result, r)
+ return result
+
+class Not(Term):
+ def __init__(self, term):
+ self.term = term
+
+ def apply(self):
+ return difference(self._all(), self.term.apply())
+
+ def _all(self):
+ # XXX may not work well/be efficient with extentcatalog
+ # XXX not very efficient in general, better to use internal
+ # IntIds datastructure but that would break abstraction..
+ intids = zapi.getUtility(IIntIds)
+ result = IFBTree()
+ for uid in intids:
+ result.insert(uid, 0)
+ return result
+
+class IndexTerm(Term):
+ def __init__(self, (catalog_name, index_name)):
+ self.catalog_name = catalog_name
+ self.index_name = index_name
+
+ def getIndex(self):
+ catalog = zapi.getUtility(ICatalog, self.catalog_name)
+ index = catalog[self.index_name]
+ return index
+
+class Text(IndexTerm):
+ def __init__(self, index_id, text):
+ super(Text, self).__init__(index_id)
+ self.text = text
+
+ def getIndex(self):
+ index = super(Text, self).getIndex()
+ assert ITextIndex.providedBy(index)
+ return index
+
+ def apply(self):
+ index = self.getIndex()
+ return index.apply(self.text)
+
+class FieldTerm(IndexTerm):
+ def getIndex(self):
+ index = super(FieldTerm, self).getIndex()
+ assert IFieldIndex.providedBy(index)
+ return index
+
+class Eq(FieldTerm):
+ def __init__(self, index_id, value):
+ assert value is not None
+ super(Eq, self).__init__(index_id)
+ self.value = value
+
+ def apply(self):
+ return self.getIndex().apply((self.value, self.value))
+
+class NotEq(FieldTerm):
+ def __init__(self, index_id, not_value):
+ super(NotEq, self).__init__(index_id)
+ self.not_value = not_value
+
+ def apply(self):
+ index = self.getIndex()
+ all = index.apply((None, None))
+ r = index.apply((self.not_value, self.not_value))
+ return difference(all, r)
+
+class Between(FieldTerm):
+ def __init__(self, index_id, min_value, max_value):
+ super(Between, self).__init__(index_id)
+ self.min_value = min_value
+ self.max_value = max_value
+
+ def apply(self):
+ return self.getIndex().apply((self.min_value, self.max_value))
+
+class Ge(Between):
+ def __init__(self, index_id, min_value):
+ super(Ge, self).__init__(index_id, min_value, None)
+
+class Le(Between):
+ def __init__(self, index_id, max_value):
+ super(Le, self).__init__(index_id, None, max_value)
+
+class In(FieldTerm):
+ def __init__(self, index_id, values):
+ assert None not in values
+ super(In, self).__init__(index_id)
+ self.values = values
+
+ def apply(self):
+ results = []
+ index = self.getIndex()
+ for value in self.values:
+ r = index.apply((value, value))
+ # empty results
+ if not r:
+ continue
+ results.append(r)
+
+ if not results:
+ # no applicable terms at all
+ return IFBTree()
+
+ result = results.pop(0)
+ for r in results:
+ result = union(result, r)
+ return result
+
Added: z3/hurry/trunk/src/hurry/query/query.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/query.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,269 @@
+Hurry Query
+===========
+
+The hurry query system for the Zope 3 catalog builds on catalog
+indexes as defined in Zope 3 core, as well as the indexes in
+zc.catalog. It is in part inspired by AdvancedQuery for Zope 2 by
+Dieter Maurer, though has an independent origin.
+
+Setup
+-----
+
+Let's define a simple content object. First its interface::
+
+ >>> from zope.interface import Interface, Attribute, implements
+ >>> class IContent(Interface):
+ ... f1 = Attribute('f1')
+ ... f2 = Attribute('f2')
+ ... f3 = Attribute('f3')
+ ... f4 = Attribute('f4')
+ ... t1 = Attribute('t1')
+ ... t2 = Attribute('t2')
+
+And its implementation::
+
+ >>> from zope.app.container.contained import Contained
+ >>> class Content(Contained):
+ ... implements(IContent)
+ ... def __init__(self, id, f1='', f2='', f3='', f4='', t1='', t2=''):
+ ... self.id = id
+ ... self.f1 = f1
+ ... self.f2 = f2
+ ... self.f3 = f3
+ ... self.f4 = f4
+ ... self.t1 = t1
+ ... self.t2 = t2
+ ... def __cmp__(self, other):
+ ... return cmp(self.id, other.id)
+
+The id attribute is just so we can identify objects we find again
+easily. By including the __cmp__ method we make sure search results
+can be stably sorted.
+
+We use a fake int id utility here so we can test independent of
+the full-blown zope environment::
+
+ >>> from zope.interface import verify
+ >>> from zope import interface
+ >>> import zope.app.intid.interfaces
+ >>> from zope.app.testing import ztapi
+ >>> class DummyIntId(object):
+ ... interface.implements(zope.app.intid.interfaces.IIntIds)
+ ... MARKER = '__dummy_int_id__'
+ ... def __init__(self):
+ ... self.counter = 0
+ ... self.data = {}
+ ... def register(self, obj):
+ ... intid = getattr(obj, self.MARKER, None)
+ ... if intid is None:
+ ... setattr(obj, self.MARKER, self.counter)
+ ... self.data[self.counter] = obj
+ ... intid = self.counter
+ ... self.counter += 1
+ ... return intid
+ ... def getObject(self, intid):
+ ... return self.data[intid]
+ ... def __iter__(self):
+ ... return iter(self.data)
+ >>> intid = DummyIntId()
+ >>> ztapi.provideUtility(
+ ... zope.app.intid.interfaces.IIntIds, intid)
+
+Now let's register a catalog::
+
+ >>> from zope.app.catalog.interfaces import ICatalog
+ >>> from zope.app.catalog.catalog import Catalog
+ >>> catalog = Catalog()
+ >>> ztapi.provideUtility(ICatalog, catalog, 'catalog1')
+
+And set it up with various indexes::
+
+ >>> from zope.app.catalog.field import FieldIndex
+ >>> from zope.app.catalog.text import TextIndex
+ >>> catalog['f1'] = FieldIndex('f1', IContent)
+ >>> catalog['f2'] = FieldIndex('f2', IContent)
+ >>> catalog['f3'] = FieldIndex('f3', IContent)
+ >>> catalog['f4'] = FieldIndex('f4', IContent)
+ >>> catalog['t1'] = TextIndex('t1', IContent)
+ >>> catalog['t2'] = TextIndex('t2', IContent)
+
+Now let's create some objects so that they'll be cataloged::
+
+ >>> content = [
+ ... Content(1, 'a', 'b', 'd'),
+ ... Content(2, 'a', 'c'),
+ ... Content(3, 'X', 'c'),
+ ... Content(4, 'a', 'b', 'e'),
+ ... Content(5, 'X', 'b', 'e'),
+ ... Content(6, 'Y', 'Z')]
+
+And catalog them now::
+
+ >>> for entry in content:
+ ... catalog.index_doc(intid.register(entry), entry)
+
+Now let's register a query utility::
+
+ >>> from hurry.query.query import Query
+ >>> from hurry.query.interfaces import IQuery
+ >>> ztapi.provideUtility(IQuery, Query())
+
+Set up some code to make querying and display the result
+easy::
+
+ >>> from zope.app import zapi
+ >>> from hurry.query.interfaces import IQuery
+ >>> def displayQuery(q):
+ ... query = zapi.getUtility(IQuery)
+ ... r = query.searchResults(q)
+ ... return [e.id for e in sorted(list(r))]
+
+FieldIndex Queries
+------------------
+
+Now for a query where f1 equals a::
+
+ >>> from hurry.query import Eq
+ >>> f1 = ('catalog1', 'f1')
+ >>> displayQuery(Eq(f1, 'a'))
+ [1, 2, 4]
+
+Not equals (this is more efficient than the generic ~ operator)::
+
+ >>> from hurry.query import NotEq
+ >>> displayQuery(NotEq(f1, 'a'))
+ [3, 5, 6]
+
+Testing whether a field is in a set::
+
+ >>> from hurry.query import In
+ >>> displayQuery(In(f1, ['a', 'X']))
+ [1, 2, 3, 4, 5]
+
+Whether documents are in a specified range::
+
+ >>> from hurry.query import Between
+ >>> displayQuery(Between(f1, 'X', 'Y'))
+ [3, 5, 6]
+
+You can leave out one end of the range::
+
+ >>> displayQuery(Between(f1, 'X', None)) # 'X' < 'a'
+ [1, 2, 3, 4, 5, 6]
+ >>> displayQuery(Between(f1, None, 'X'))
+ [3, 5]
+
+You can also use greater-equals and lesser-equals for the same purpose::
+
+ >>> from hurry.query import Ge, Le
+ >>> displayQuery(Ge(f1, 'X'))
+ [1, 2, 3, 4, 5, 6]
+ >>> displayQuery(Le(f1, 'X'))
+ [3, 5]
+
+It's also possible to use not with the ~ operator::
+
+ >>> displayQuery(~Eq(f1, 'a'))
+ [3, 5, 6]
+
+Using and (&)::
+
+ >>> f2 = ('catalog1', 'f2')
+ >>> displayQuery(Eq(f1, 'a') & Eq(f2, 'b'))
+ [1, 4]
+
+Using or (|)::
+
+ >>> displayQuery(Eq(f1, 'a') | Eq(f2, 'b'))
+ [1, 2, 4, 5]
+
+These can be chained::
+
+ >>> displayQuery(Eq(f1, 'a') & Eq(f2, 'b') & Between(f1, 'a', 'b'))
+ [1, 4]
+ >>> displayQuery(Eq(f1, 'a') | Eq(f1, 'X') | Eq(f2, 'b'))
+ [1, 2, 3, 4, 5]
+
+And nested::
+
+ >>> displayQuery((Eq(f1, 'a') | Eq(f1, 'X')) & (Eq(f2, 'b') | Eq(f2, 'c')))
+ [1, 2, 3, 4, 5]
+
+"and" and "or" can also be spelled differently::
+
+ >>> from hurry.query import And, Or
+ >>> displayQuery(And(Eq(f1, 'a'), Eq(f2, 'b')))
+ [1, 4]
+ >>> displayQuery(Or(Eq(f1, 'a'), Eq(f2, 'b')))
+ [1, 2, 4, 5]
+
+Combination of In and &
+-----------------------
+
+A combination of 'In' and '&'::
+
+ >>> displayQuery(In(f1, ['a', 'X', 'Y', 'Z']))
+ [1, 2, 3, 4, 5, 6]
+ >>> displayQuery(In(f1, ['Z']))
+ []
+ >>> displayQuery(In(f1, ['a', 'X', 'Y', 'Z']) & In(f1, ['Z']))
+ []
+
+SetIndex queries
+----------------
+
+The SetIndex is defined in zc.catalog. Let's make a catalog which uses
+it::
+
+ >>> intid = DummyIntId()
+ >>> ztapi.provideUtility(
+ ... zope.app.intid.interfaces.IIntIds, intid)
+ >>> from zope.app.catalog.interfaces import ICatalog
+ >>> from zope.app.catalog.catalog import Catalog
+ >>> catalog = Catalog()
+ >>> ztapi.provideUtility(ICatalog, catalog, 'catalog1')
+ >>> from zc.catalog.catalogindex import SetIndex
+ >>> catalog['f1'] = SetIndex('f1', IContent)
+ >>> catalog['f2'] = FieldIndex('f2', IContent)
+
+First let's set up some new data::
+
+ >>> content = [
+ ... Content(1, ['a', 'b', 'c'], 1),
+ ... Content(2, ['a'], 1),
+ ... Content(3, ['b'], 1),
+ ... Content(4, ['c', 'd'], 2),
+ ... Content(5, ['b', 'c'], 2),
+ ... Content(6, ['a', 'c'], 2)]
+
+And catalog them now::
+
+ >>> for entry in content:
+ ... catalog.index_doc(intid.register(entry), entry)
+
+Now do a a 'any of' query, which returns all documents that
+contain any of the values listed::
+
+ >>> from hurry.query.set import AnyOf
+ >>> displayQuery(AnyOf(f1, ['a', 'c']))
+ [1, 2, 4, 5, 6]
+ >>> displayQuery(AnyOf(f1, ['c', 'b']))
+ [1, 3, 4, 5, 6]
+ >>> displayQuery(AnyOf(f1, ['a']))
+ [1, 2, 6]
+
+Do a 'all of' query, which returns all documents that
+contain all of the values listed::
+
+ >>> from hurry.query.set import AllOf
+ >>> displayQuery(AllOf(f1, ['a']))
+ [1, 2, 6]
+ >>> displayQuery(AllOf(f1, ['a', 'b']))
+ [1]
+ >>> displayQuery(AllOf(f1, ['a', 'c']))
+ [1, 6]
+
+We can combine this with other queries::
+
+ >>> displayQuery(AnyOf(f1, ['a']) & Eq(f2, 1))
+ [1, 2]
Added: z3/hurry/trunk/src/hurry/query/set.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/set.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,54 @@
+from zc.catalog.interfaces import ICatalogSetIndex
+from hurry.query import query
+
+class SetTerm(query.IndexTerm):
+ def getIndex(self):
+ index = super(SetTerm, self).getIndex()
+ assert ICatalogSetIndex.providedBy(index)
+ return index
+
+class AnyOf(SetTerm):
+ def __init__(self, index_id, values):
+ super(AnyOf, self).__init__(index_id)
+ self.values = values
+
+ def apply(self):
+ return self.getIndex().apply({'any_of': self.values})
+
+class AllOf(SetTerm):
+ def __init__(self, index_id, values):
+ super(AllOf, self).__init__(index_id)
+ self.values = values
+
+ def apply(self):
+ return self.getIndex().apply({'all_of': self.values})
+
+class SetBetween(SetTerm):
+ def __init__(self, index_id,
+ minimum=None, maximum=None,
+ include_minimum=False, include_maximum=False):
+ super(SetBetween, self).__init__(index_id)
+ self.tuple = (minimum, maximum, include_minimum, include_maximum)
+
+ def apply(self):
+ return self.getIndex().apply({'between': self.tuple})
+
+class ExtentAny(SetTerm):
+ """Any ids in the extent that are indexed by this index.
+ """
+ def __init__(self, index_id, extent):
+ super(Any, self).__init__(index_id)
+ self.extent = extent
+
+ def apply(self):
+ return self.getIndex().apply({'any': self.extent})
+
+class ExtentNone(SetTerm):
+ """Any ids in the extent that are not indexed by this index.
+ """
+ def __init__(self, extent):
+ super(None, self).__init__(index_id)
+ self.extent = extent
+
+ def apply(self):
+ return self.getIndex().apply({'none': self.extent})
Added: z3/hurry/trunk/src/hurry/query/tests.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/query/tests.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,10 @@
+import unittest
+from zope.testing import doctest
+
+def test_suite():
+ return unittest.TestSuite((
+ doctest.DocFileSuite('query.txt'),
+ ))
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
Added: z3/hurry/trunk/src/hurry/workflow/__init__.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/__init__.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1 @@
+# this is a package
Added: z3/hurry/trunk/src/hurry/workflow/configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,15 @@
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ >
+
+ <localUtility class=".workflow.Workflow">
+ <factory
+ id="hurry.workflow.Workflow"
+ />
+ <require
+ interface=".interfaces.IWorkflow"
+ permission="zope.Public"
+ />
+ </localUtility>
+
+</configure>
Added: z3/hurry/trunk/src/hurry/workflow/interfaces.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/interfaces.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,171 @@
+from zope.interface import Interface, Attribute
+from zope.app.event.interfaces import IObjectEvent
+
+MANUAL = 0
+AUTOMATIC = 1
+
+class InvalidTransitionError(Exception):
+ 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 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 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: z3/hurry/trunk/src/hurry/workflow/tests.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/tests.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,73 @@
+import unittest
+
+from zope.testing import doctest
+from zope.app.testing import placelesssetup, ztapi
+from zope.app.annotation import interfaces as annotation_interfaces
+from zope.app.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):
+ placelesssetup.setUp()
+ ztapi.provideAdapter(annotation_interfaces.IAnnotatable,
+ interfaces.IWorkflowState,
+ workflow.WorkflowState)
+ ztapi.provideAdapter(annotation_interfaces.IAnnotatable,
+ interfaces.IWorkflowInfo,
+ workflow.WorkflowInfo)
+ ztapi.provideAdapter(annotation_interfaces.IAttributeAnnotatable,
+ annotation_interfaces.IAnnotations,
+ attribute.AttributeAnnotations)
+ ztapi.provideUtility(interfaces.IWorkflowVersions,
+ WorkflowVersions())
+
+def test_suite():
+ return unittest.TestSuite((
+ doctest.DocFileSuite(
+ 'workflow.txt',
+ setUp=workflowSetUp, tearDown=placelesssetup.tearDown,
+ ),
+ ))
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
+
Added: z3/hurry/trunk/src/hurry/workflow/workflow.py
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/workflow.py Fri Sep 9 18:04:08 2005
@@ -0,0 +1,283 @@
+import random, sys
+
+from persistent import Persistent
+from zope.interface import implements
+from zope.event import notify
+from zope.security.management import getInteraction, NoInteraction
+from zope.security.interfaces import Unauthorized
+from zope.security.checker import CheckerPublic
+
+from zope.app import zapi
+from zope.app.annotation.interfaces import IAnnotations
+from zope.app.container.contained import Contained
+from zope.app.event.objectevent import ObjectEvent, ObjectModifiedEvent
+
+from hurry.workflow import interfaces
+from hurry.workflow.interfaces import MANUAL, AUTOMATIC
+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):
+ 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
+
+ def __cmp__(self, other):
+ return cmp(self.order, other.order)
+
+class Workflow(Persistent, Contained):
+ 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 = zapi.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 = zapi.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 fireTransitionForVersions(self, state, transition_id):
+ id = IWorkflowState(self.context).getId()
+ wf_versions = zapi.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 = zapi.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 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 = zapi.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: z3/hurry/trunk/src/hurry/workflow/workflow.txt
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/src/hurry/workflow/workflow.txt Fri Sep 9 18:04:08 2005
@@ -0,0 +1,706 @@
+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.app.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 or AUTOMATIC. MANUAL indicates
+user action is needed to fire the transition; AUTOMATIC transitions
+fire automatically.
+
+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.app.testing import ztapi
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+
+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)
+ >>> ztapi.subscribe([interfaces.IWorkflowTransitionEvent], None,
+ ... transition_handler)
+
+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']
+
+Since this is a manually triggered transition, we can fire this
+transition::
+
+ >>> info.fireTransition('a_to_b')
+
+The workflow state should now be 'b'::
+
+ >>> interfaces.IWorkflowState(document).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'
+
+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])
+
+ >>> from zope.app.testing import ztapi
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+
+Let's get the workflow_versions utility which we can use to track
+versions and come up with a new unique id::
+
+ >>> from zope.app import zapi
+ >>> workflow_versions = zapi.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])
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+
+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'
+
+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])
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+
+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])
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+ >>> 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])
+ >>> ztapi.provideUtility(interfaces.IWorkflow, wf)
+ >>> 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
Added: z3/hurry/trunk/zc.catalog-configure.zcml
==============================================================================
--- (empty file)
+++ z3/hurry/trunk/zc.catalog-configure.zcml Fri Sep 9 18:04:08 2005
@@ -0,0 +1,5 @@
+<!-- install this into your Zope 3 instance's etc/package-includes
+ directory. You need zc.catalog installed in your Zope 3 installation.
+ Right now you can get it from Zope 3 svn in Sandbox/zc/catalog/)
+-->
+<include package="zc.catalog"/>
More information about the z3-checkins
mailing list