blog

Plone and Dexterity: Enable behaviors per content type instance

| categories: zope, dexterity, adapters, plone, behavior

plone.behaviors are great, but because they are stored in the FTI, they are enabled for all instances of a content type. This blogpost will show how you can enable a plone.behavior for a specific instance.

Note

This blogpost assumes that you are already familiar with a lot of the Zope/Plone programming technologies and concepts. Every time I mention a specific add-on or technology, I've added a link to documentation.

Note

UPDATE: There is now a add-on for Plone based upon the approach taken in this blog post, please see: collective.instancebehavior.

Behaviors, provided by the plone.behavior package, provide a very useful way of extending the functionality of a Dexterity content type. Unfortunately, the fact that a content type's behaviors are stored in its factory contents information (FTI) inside the portal_types tool, means that they are class (or type) specific, and not instance specific. This means that behaviors can only be enabled globally, in other words only for all the instances of a specific type.

So what can we do when we want to enable behaviors only for a specific instance of a content type? At Syslab we for example had a client who wanted a basic folderish Workspace content type, that can be extended into a more featureful custom Project type.

To enable per instance behaviors, we would need a way to store those behaviors on the instance. I decided to use annotations for this purpose. Additionally we must create our own IBehaviorAssignable adapter that overrides the default one to not only look for an object's FTI registered annotations, but also look for the behaviors stored in the current instance's annotations.

I created this adapter in a module named behavior.py:

from zope.annotation import IAnnotations
from zope.component import adapts, queryUtility

from plone.behavior.interfaces import IBehavior
from plone.dexterity.behavior import DexterityBehaviorAssignable

from myproject.types.workspace import IWorkspace
from myproject.types.config import INSTANCE_BEHAVIORS_KEY as KEY

class DexterityInstanceBehaviorAssignable(DexterityBehaviorAssignable):
    """ Support per instance specification of plone.behavior behaviors
    """
    adapts(IWorkspace)

    def __init__(self, context):
        super(DexterityInstanceBehaviorAssignable, self).__init__(context)
        annotations = IAnnotations(context)
        self.instance_behaviors = annotations.get(KEY, ())

    def enumerateBehaviors(self):
        self.behaviors = self.fti.behaviors + self.instance_behaviors
        for name in self.behaviors:
            behavior = queryUtility(IBehavior, name=name)
            if behavior is not None:
                yield behavior

Now register this adapter via ZCML in configure.zcml:

<adapter factory=".behavior.DexterityInstanceBehaviorAssignable" />

We know what we want to use as a storing mechanism (Annotations) for per instance behaviors, and we have a custom adapter that knows to look for them, but we still need a way to save new behaviors to a specific instance's annotations.

In my case, I wanted to extend the Workspace content type with a Project behavior that provides additional fields. Refer to my blogpost on schema-extending a dexterity type for an explanation on how to create such a custom behavior. This was however only my specific use-case, and any behavior can be applied in this way.

To provide a mechanism for saving new per instance behaviors, I created a browser view @@enable_project, which can be called on any Workspace instance. When it gets called, my custom behavior (with full path myproduct.types.project.IProject) gets saved in the annotations of the specific Workspace.

class EnableProject(grok.View):
    grok.context(IWorkspace)
    grok.name('enable_project')
    grok.require('cmf.ModifyPortalContent')

    def render(self):
        context = aq_inner(self.context)
        annotations = IAnnotations(context)
        instance_behaviors = annotations.get(KEY, ())
        instance_behaviors += ('myprodcouct.types.project.IProject',)
        annotations[KEY] = instance_behaviors

        IStatusMessage(self.request).add(
            _(u"This Workpace has now been turned into a Project."),
            u"info")
        return self.request.RESPONSE.redirect(
                '/'.join(context.getPhysicalPath()))

You may want to provide a UI mechanism for enabling per instance behaviors. I decided to do this by adding a new object_buttons action in the actions.xml Generic Setup file:

<?xml version="1.0"?>
<object name="portal_actions" meta_type="Plone Actions Tool"
   xmlns:i18n="http://xml.zope.org/namespaces/i18n">
 <object name="object_buttons" meta_type="CMF Action Category">
  <property name="title"></property>
  <object name="enable_project" meta_type="CMF Action" i18n:domain="plone">
   <property name="title" i18n:translate="">Enable project</property>
   <property name="description" i18n:translate=""></property>
   <property
      name="url_expr">string:$object_url/@@enable_project</property>
   <property
      name="available_expr">python:context.portal_type == 'myproject.types.workspace' and not context.is_project() or False</property>
   <property name="permissions">
    <element value="Modify portal content"/>
   </property>
   <property name="visible">True</property>
  </object>
 </object>
</object>

This provides a convenient way (via the actions dropdown) for the end user to turn a Workspace into a Project.