[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