Creating Content Rule Conditions and Actions

« Return to page index

This tutorial shows how to create a new type of condition that site administrators can use to construct Content Rules in Plone 3.

Introduction

What are we doing here?

Plone 3 adds a new feature called Content Rules. Through Plone's Site Setup, administrators can create rules which are then assigned to one or more folders and trigger when content in a given folder is added, removed or changed. A content rule is composed of a series of conditions followed by a sequence of actions that are executed if the conditions are true.

The standard conditions can check aspects such as content type or workflow state. In this tutorial, we will show how to create a new condition that checks for keywords on the content item in question. Although the example shows how to create a new type of condition, the process for creating a new type of action is very similar. We will aim to illustrate differences between the two where they occur.

We will assume that you are already familiar with how to use content rules through the Plone user interface, and how to create and install new egg-based add-on products in Plone 3.

Creating the package

First, we must create a new package to house our new condition

We will use an egg-based package and install it into a buildout. See the buildout tutorial for more information about how to install Paste Script and ZopeSkel, and how to add new packages to a buildout.

First, we create a package in the src/ directory of a Plone 3 buildout:

$ cd src
$ paster create -t plone collective.keywordcondition
Selected and implied templates:
  ZopeSkel#basic_namespace  A project with a namespace package
  ZopeSkel#plone            A Plone project

Variables:
  egg:      collective.keywordcondition
  package:  collectivekeywordcondition
  project:  collective.keywordcondition
Enter namespace_package (Namespace package (like plone)) ['plone']: collective
Enter package (The package contained namespace package (like example)) ['example']: keywordcondition
Enter zope2product (Are you creating a Zope 2 Product?) [False]: False
...
Enter zip_safe (True/False: if the package can be distributed as a .zip file) [False]: False

The important questions to answer are the namespace package and package name (here corresponding to the egg name, collective.keywordcondition), the zope2product status (False, in this case - we do not need GenericSetup product installation or other
Zope 2-like behaviour), and the zip_safe flag (False - as with all Zope packages).

Next, we must install this as a develop egg so that Zope can pick it up, and add a ZCML slug so that its ZCML is loaded on Zope startup. With a buildout-based environment, we do this by editing buildout.cfg:

[buildout]
...
eggs =
  ...
  collective.keywordcondition

developer =
  ...
  src/collective.keywordcondition

...

[instance]
...
zcml =
  ...
  collective.keywordcondition

Please refer to the buildout tutorial for more information. After making the changes, we must re-run buildout:

$ ./bin/buildout -No

Adding the condition

Now we can add the required boilerplate, component registrations and the actual logic of the condition

A Content Rules condition (or action) consists of:

  1. An interface that describes the configurable aspects of the condition.
  2. A persistent object that is used to store the configuration of the condition.
  3. An executor adapter, which is used to execute the condition when the rule is executed
  4. An add view and an edit view, which let the end user add and configure the condition

We will place all of these in a file called keyword.py inside the collective.keywordcondition package.

Storing the condition settings

First, we import a few things.

from persistent import Persistent 
from OFS.SimpleItem import SimpleItem

from zope.interface import implements, Interface
from zope.component import adapts
from zope.formlib import form
from zope import schema
from zope.app.component.hooks import getSite

from zope.component.interfaces import IObjectEvent

from plone.contentrules.rule.interfaces import IExecutable, IRuleElementData

from plone.app.contentrules.browser.formhelper import AddForm, EditForm 

from Acquisition import aq_inner

from collective.keywordcondition import MessageFactory as _

Notice the message factory, which is used for translation purposes. It is defined in the __init__.py file in the collective.keywordcondition package like so:

from zope.i18nmessageid import MessageFactory
MessageFactory = MessageFactory('collective.keywordcondition')

Then, we define an interface using zope.schema, which describes the configurable aspects of the condition: in this case, a list of keywords. The title, description and other metadata will be used by zope.formlib in the add and edit forms defined later. See the zope.schema package for more information about which field types are available and how they can be configured.

class IKeywordCondition(Interface):
    """Interface for the configurable aspects of a keyword condition.
    
    This is also used to create add and edit forms, below.
    """
    
    keywords = schema.Tuple(title=_(u"Keywords"),
                            description=_(u"The keywords to check for."),
                            required=True,
                            value_type=schema.TextLine(title=_(u"Keyword")))

Next, we create the actual condition storage implementation.

class KeywordCondition(SimpleItem):
    """The actual persistent implementation of the keyword condition element.
    
    Note that we must mix in SimpleItem to keep Zope 2 security happy.
    """
    implements(IKeywordCondition, IRuleElementData)
    
    keywords = []
    element = "collective.keywordcondition.Keyword"
    
    @property
    def summary(self):
        return _(u"Keywords contains: ${names}", mapping=dict(names=", ".join(self.keywords)))

The keywords variable sets the default value of the field defined in the aforementioned IKeywordCondition interface. The element variable specifies a unique name for this rule element (conditions and actions are commonly referred to as rule elements). By convention, this is a dotted name that includes the package name, so as to guarantee uniqueness. The element name will be referenced again in the configure.zcml file shortly.

The storage object must also implement a summary property. This is used in the Content Rules control panel to given a summary of the condition when a rule is being viewed.

The executor

After defining the storage for the condition, we define an executor

class KeywordConditionExecutor(object):
    """The executor for this condition.
    
    This is registered as an adapter in configure.zcml
    """
    implements(IExecutable)
    adapts(Interface, IKeywordCondition, IObjectEvent)
         
    def __init__(self, context, element, event):
        self.context = context
        self.element = element
        self.event = event

    def __call__(self):
        context = aq_inner(self.event.object)
        keywords = frozenset()
        try:
            keywords = frozenset(context.Subject())
        except (AttributeError, TypeError,):
            # The object doesn't have a Subject method
            return False
        return (len(keywords.intersection(self.element.keywords)) > 0)

This is simply an adapter that adapts the context content object type (in this case, we don't care about what type of object we use, and so we specify Interface, the most general interface), the condition type (our newly defined IKeywordCondition interface), and the event type (in this case, the IObjectEvent, a generic interface that describes events about objects). We will come back to the event registration in a moment.

It is possible to override the implementation of the executor for different context types or different event types, by registering additional adapters.

The executor has one important method - __call__() - which is called during rule execution. It must return True or False. If it returns True, rule execution will continue with the next condition or action, otherwise the rule will be aborted.

If you are creating an action, your executor adapter will be doing the actual work of the action. It should still return True or False to indicate whether rule processing can continue.

Notice how the executor is coded quite defensively. Since rules may be invoked on a variety of events, in a variety of circumstances, we cannot make too many assumptions about what we get as the context object or the event. The implementation itself simply checks whether the set of keywords that were entered by the user intersects with the set of keywords that are defined on the event context.

Add and edit forms

Finally, we must define add and edit views that are used to create and modify an instance of our new condition. We use the zope.formlib library and our IKeywordCondition interface like so:

class KeywordAddForm(AddForm):
    """An add form for portal type conditions.
    """
    form_fields = form.FormFields(IKeywordCondition)
    label = _(u"Add Keyword Condition")
    description = _(u"A keyword condition makes the rule apply only to content with certain keywords.")
    form_name = _(u"Configure element")
    
    def create(self, data):
        c = KeywordCondition()
        form.applyChanges(c, self.form_fields, data)
        return c

class KeywordEditForm(EditForm):
    """An edit form for portal type conditions
    """
    form_fields = form.FormFields(IKeywordCondition)
    label = _(u"Edit Keyword Condition")
    description = _(u"A keyword condition makes the rule apply only to content with certain keywords.")
    form_name = _(u"Configure element")

The AddForm and EditForm base classes are helpers from plone.app.contentrules. They provide us with standard buttons and validators.

Configuring the components

With the code in place, we must register our components in configure.zcml:

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:browser="http://namespaces.zope.org/browser"
    xmlns:plone="http://namespaces.plone.org/plone"
    xmlns:five="http://namespaces.zope.org/five"
    i18n_domain="collective.keywordcondition">

    <include package="plone.contentrules" />
    <include package="plone.contentrules" file="meta.zcml" />
    
    <!-- the keyword condition -->
    <adapter factory=".keyword.KeywordConditionExecutor" />

    <browser:page 
      for="plone.app.contentrules.browser.interfaces.IRuleConditionAdding"
      name="collective.keywordcondition.Keyword"
      class=".keyword.KeywordAddForm"
      permission="cmf.ManagePortal"
      />

    <browser:page 
      for="collective.keywordcondition.keyword.IKeywordCondition"
      name="edit"
      class=".keyword.KeywordEditForm"
      permission="cmf.ManagePortal"
      />

    <plone:ruleCondition
        name="collective.keywordcondition.Keyword"
        title="Keyword"
        description="Apply only when the current content object has one of the given keywords"
        for="*"
        event="zope.component.interfaces.IObjectEvent"
        addview="collective.keywordcondition.Keyword"
        editview="edit"
        />

</configure>

Here, we first import the necessary configuration elements from plone.app.contentrules and then register the executor adapter, the add view and the edit view. The name of the add view should correspond to the element name (collective.keywordcondition.Keyword in this case) as it was defined in the element class above. The name of the edit view should always be edit.

Finally, we register the condition type itself.

When creating an action, use the <plone:ruleAction /> directive. It uses the same attributes as <plone:ruleCondition />

Again, the name must correspond to the element name defined in the element class. The title and description are used in the user interface. The for and event attributes are used to control when the rule element will be available. In this case, it is available for any context type, and any object event.

Most of the events registered for use with the Content Rules system inherit from IObjectEvent. This interface simply guarantees that there will be an object attribute on the event instance that contains the object for which the event took place. If you want to restrict the condition to a more specific type of event, you can specify a different interface here. This is generally only necessary if you need additional information in order to execute the condition. For example, the "workflow transition" condition that comes with plone.app.contentrules is registered for Products.CMFCore.interfaces.IActionSucceededEvent, since it needs to determine the workflow transition that took place, and this is only sent as part of this particular type of event. If you do not care about the event type at all, you can sepcify "*" as the event type.

There must be at least one executor adapter applicable for event that the condition or action is registered for. In this example, we registered the executor as an adapter of IObjectEvent, and restricted the condition to only be available for events of this type.

Adding tests

Of course, we need automated tests!

No code is complete without tests. If you didn't know that, go read the testing tutorial. The tests, in tests.py, are inspired by those in plone.app.contentrules, and ensure that the registration and setup worked correctly, and that our logic in the condition executor works as expected:

import unittest

from Products.Five import zcml
from Products.Five import fiveconfigure
from Products.PloneTestCase import PloneTestCase as ptc
from Products.PloneTestCase.layer import PloneSite

from zope.interface import implements, Interface
from zope.component import getUtility, getMultiAdapter

from zope.component.interfaces import IObjectEvent

from plone.contentrules.engine.interfaces import IRuleStorage
from plone.contentrules.rule.interfaces import IRuleCondition
from plone.contentrules.rule.interfaces import IExecutable

from plone.app.contentrules.rule import Rule

import collective.keywordcondition

from collective.keywordcondition.keyword import KeywordCondition
from collective.keywordcondition.keyword import KeywordEditForm

ptc.setupPloneSite()

class DummyEvent(object):
    implements(IObjectEvent)
    
    def __init__(self, obj):
        self.object = obj

# Since we do not need to quick-install anything or register a Zope 2 style
# product, we can use a simple layer that's set up after the Plone site has 
# been created above

class TestKeywordCondition(ptc.PloneTestCase):
    class layer(PloneSite):
        @classmethod
        def setUp(cls):
            fiveconfigure.debug_mode = True
            zcml.load_config('configure.zcml',
                             collective.keywordcondition)
            fiveconfigure.debug_mode = False

        @classmethod
        def tearDown(cls):
            pass

    def afterSetUp(self):
        self.setRoles(('Manager',))
        
    def testRegistered(self): 
        element = getUtility(IRuleCondition, name='collective.keywordcondition.Keyword')
        self.assertEquals('collective.keywordcondition.Keyword', element.addview)
        self.assertEquals('edit', element.editview)
        self.assertEquals(None, element.for_)
        self.assertEquals(IObjectEvent, element.event)
    
    def testInvokeAddView(self): 
        element = getUtility(IRuleCondition, name='collective.keywordcondition.Keyword')
        storage = getUtility(IRuleStorage)
        storage[u'foo'] = Rule()
        rule = self.portal.restrictedTraverse('++rule++foo')
        
        adding = getMultiAdapter((rule, self.portal.REQUEST), name='+condition')
        addview = getMultiAdapter((adding, self.portal.REQUEST), name=element.addview)
        
        addview.createAndAdd(data={'keywords' : ['Foo', 'Bar']})
        
        e = rule.conditions[0]
        self.failUnless(isinstance(e, KeywordCondition))
        self.assertEquals(['Foo', 'Bar'], e.keywords)
    
    def testInvokeEditView(self): 
        element = getUtility(IRuleCondition, name='collective.keywordcondition.Keyword')
        e = KeywordCondition()
        editview = getMultiAdapter((e, self.folder.REQUEST), name=element.editview)
        self.failUnless(isinstance(editview, KeywordEditForm))

    def testExecute(self): 
        e = KeywordCondition()
        e.keywords = ['Foo', 'Bar']
        
        self.folder.invokeFactory('Document', 'd1')
        self.folder.d1.setSubject(['Bar'])
        
        self.folder.invokeFactory('Document', 'd2')
        self.folder.d2.setSubject(['Baz'])
        
        ex = getMultiAdapter((self.portal, e, DummyEvent(self.folder.d1)), IExecutable)
        self.assertEquals(True, ex())
        
        ex = getMultiAdapter((self.portal, e, DummyEvent(self.folder.d2)), IExecutable)
        self.assertEquals(False, ex())

def test_suite():
    return unittest.TestSuite([
            unittest.makeSuite(TestKeywordCondition)
        ])

if __name__ == '__main__':
    unittest.main(defaultTest='test_suite')

Everything apart form the testExecute() method is largely boilerplate.

Testing it out

That's it! It's ready to be used.

If you now start Zope, you should find that the new Keyword condition type appears immediately when editing a rule in the Content Rules control panel - there is no need to install it explicitly.

The latest source code is found in the Collective svn repository. To check it out yourself, you can run:

$ svn co https://svn.plone.org/svn/collective/collective.keywordcondition/trunk collective.keywordcondition

The package has also been released to PyPI, so you can install it with buildout or easy_install as you would any other package. Don't forget to install a ZCML slug or reference it from another package's configure.zcml though.