Friday, June 25, 2010

Alfresco: Workflow with Spaces, Rules and Actions

Somewhat surprisingly, I made quite a lot of progress this week on my little Alfresco proof of concept project. Or maybe its not that surprising. I guess once you understand what you can do with these three Alfresco features - Spaces, Rules and Actions - lot of things fall just into place on their own. So anyway, this post describes my implementation of a simple (ie, content oriented) workflow using these features. A high-level description of the workflow can be found here.

The state diagram for the workflow, this time annotated with the actions that are fired on each transition, is shown above. For convenience, I have grouped the actions into three broad categories, corresponding roughly with the Drupal module that we've used to achieve similar functionality.

  • Workflow Actions
  • Scheduler Actions
  • External Publisher Actions

But first, a little digression. In order to take advantage of the recursive nature of rules (ie, a rule can be made to apply to current space and all its subspaces), I changed the folder structure a bit, as shown below.

  company_home
       |
       +-- user_homes
       |      |
       |      +-- Happy
       |      |
       |      +-- ...other bloggers...
       +-- Public
       |     |
       |     +-- Review
       |     |
       |     +-- Published
       |     |     |
       |     |     +-- Scheduled
       |     |     |
       |     |     +-- Live
       |     |     |
       |     |     +-- Archived

I also changed the content model to make my:publishable an aspect rather than have all my publishable content types (only my:post at the moment) inherit from my:publishableDoc type. This was not strictly necessary, but I wanted to add some content types in the future which don't follow the my:publishableDoc template. I have updated the relevant files in my post describing the content model.

Workflow Actions

To paraphrase the Drupal Workflow Page, this module allows the creation and assignment of an arbitary set of workflow states to a node type. Transitions between workflow states can have actions assigned to them. My workflow implementation is similar - in my system, there are 3 workflow states - Draft, Review and Published - that are baked into the custom my:publishable aspect. Node types that participate in this workflow will have the my:publishable aspect applied to their definition.

I use a combination of spaces and content rules along with custom actions to achieve this. The example in Alfresco Custom Actions Wiki Page was very helpful, as was Jeff Pott's Alfresco Developer's Guide. The information on how to actually assign an action to a space via a content rule came from Munwar Sharif's Alfresco Enterprise Content Management Implementation. The actions in this category basically change the owner and move the node to a target folder in response to a change in my:pubState. The transition rules are described in the table below:

From State To State Target Owner Target Space
Draft Review "doc" Review
Review Draft ${owner} user_homes/${owner}
Review Published no change Live/Scheduled/Archived
Published Review "doc" Review

As you can see, we can figure out the source state from the space in which the node currently resides. The target state is the current value of my:pubState. In our hypothetical fairy tale corporation, there is a single editor, so in all workflow transitions that end in Review, "doc" is our target owner - if there were multiple editors, one possibility could be to "assign" a default editor to a blogger on creation. Other editors could still review a post if needed, since they share the same permission group (GROUP_BLOGGER).

The objective of setting up the decision table above is to make my actions trigger automatically on update of the node (ie, without requiring the user to "Run this Action"). One "feature" of Alfresco actions is that to be runnable via an Action Executer, they need to have a NAME field defined, which must correspond to the bean id in the Spring context. So we have to define four actions to handle the four transitions described above. For ease of maintenance, I have put this logic into a WorkflowHelper class, and the four actions just call the assign() method on this helper class. So the action classes ended up looking very similar to one another. The table below shows the list of "workflow" actions.

Name Configured on Space Recursive? Rule Type Description
send-to-review user_homes Yes UPDATE Invoked by Blogger to change owner to "doc" and move node to Public/Review
send-to-draft Public/Review No UPDATE Invoked by Editor to change owner back to creator and move node back to user's home directory.
publish-document Public/Review No UPDATE Invoked by Editor to move node to appropriate subdirectory of Published based on value of my:pubDttm and my:unpubDttm.
unpublish-document Public/Published Yes UPDATE Invoked by Editor to move node from any subdirectory of Published back to Review.

The action executor classes basically delegate to the workflow helper, so I just show you the code for these classes without any explanation.

send-to-review

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/workflow/SendToReviewAction.java
package com.mycompany.alfresco.extension.workflow;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Change workflow state from Draft to Review.
 * Configured on: user_homes and subspaces.
 * As: UPDATE_RULE
 * Condition: aspect == my:publishable
 */
public class SendToReviewAction extends ActionExecuterAbstractBase {

  public static final String NAME = "send-to-review";

  private WorkflowHelper helper;
  
  public void setHelper(WorkflowHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.assign(actionedUponNodeRef);
    } catch (InvalidWorkflowTransitionException e) {
      throw new RuntimeException(e);
    }
  }
}

send-to-draft

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/workflow/SendToDraftAction.java
package com.mycompany.alfresco.extension.workflow;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Change workflow state from Review to Draft.
 * Configured on: Review
 * As: UPDATE_RULE
 * Condition: aspect == my:publishable
 */
public class SendToDraftAction extends ActionExecuterAbstractBase {
  
  public static final String NAME = "send-to-draft";
  
  private WorkflowHelper helper;

  public void setHelper(WorkflowHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.assign(actionedUponNodeRef);
    } catch (InvalidWorkflowTransitionException e) {
      throw new RuntimeException(e);
    }
  }
}

publish-document

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/workflow/PublishDocumentAction.java
package com.mycompany.alfresco.extension.workflow;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Change workflow state from Review to Published.
 * Configured on: Review
 * As: UPDATE_RULE
 * Condition: aspect == my:publishable
 */
public class PublishDocumentAction extends ActionExecuterAbstractBase {

  public static final String NAME = "publish-document";

  private WorkflowHelper helper;
  
  public void setHelper(WorkflowHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.assign(actionedUponNodeRef);
    } catch (InvalidWorkflowTransitionException e) {
      throw new RuntimeException(e);
    }
  }
}

unpublish-document

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/workflow/UnpublishDocumentAction.java
package com.mycompany.alfresco.extension.workflow;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Change workflow state from Published to Review.
 * Configured on: Public and subspaces
 * As: UPDATE_RULE
 * Condition: aspect == my:publishable
 */
public class UnpublishDocumentAction extends ActionExecuterAbstractBase {

  public static final String NAME = "unpublish-document";
  
  private WorkflowHelper helper;
  
  public void setHelper(WorkflowHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.assign(actionedUponNodeRef);
    } catch (InvalidWorkflowTransitionException e) {
      throw new RuntimeException(e);
    }
  }
}

WorkFlow Helper

The workflow helper is where most of the work happens. As mentioned earlier, it is possible for us to "know" what to do based on the current location and the state of the node, so thats what we do here. The init() method maps "interesting" folder nmes (specified in Spring configuration) to the corresponding NodeRef - this makes the configuration a bit easier to read.

The assign() method is used by the actions described above. We also have a move() method that is used by my scheduler "module" described below - this is mostly a convenience and an attempt to reuse the folder name to NodeRef mapping extracted in the init() method.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// Source: src/java/com/mycompany/alfresco/extension/workflow/WorkflowHelper.java
package com.mycompany.alfresco.extension.workflow;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.transaction.UserTransaction;

import org.alfresco.model.ContentModel;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.cmr.security.AccessStatus;
import org.alfresco.service.cmr.security.AuthenticationService;
import org.alfresco.service.cmr.security.PermissionService;
import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.service.namespace.QName;
import org.alfresco.service.transaction.TransactionService;
import org.apache.log4j.Logger;
import org.springframework.util.CollectionUtils;

import com.mycompany.alfresco.extension.model.MyContentModel;

/**
 * Does the heavy lifting for most of the workflow actions
 * in our hypothetical system.
 */
public class WorkflowHelper {

  private final Logger logger = Logger.getLogger(getClass());

  private TransactionService transactionService;
  private SearchService searchService;
  private NodeService nodeService;
  private PersonService personService;
  private AuthenticationService authenticationService;
  private PermissionService permissionService;
  private Map<String,String> folderPaths;
  
  private Map<String,NodeRef> folderRefs;
  
  public void setTransactionService(TransactionService transactionService) {
    this.transactionService = transactionService;
  }
  
  public void setSearchService(SearchService searchService) {
    this.searchService = searchService;
  }
  
  public void setNodeService(NodeService nodeService) {
    this.nodeService = nodeService;
  }

  public void setPersonService(PersonService personService) {
    this.personService = personService;
  }
  
  public void setAuthenticationService(AuthenticationService authService) {
    this.authenticationService = authService;
  }
  
  public void setPermissionService(PermissionService permissionService) {
    this.permissionService = permissionService;
  }
  
  public void setFolderPaths(Map<String,String> folderPaths) {
    this.folderPaths = folderPaths;
  }
  
  protected void init() throws Exception {
    folderRefs = new HashMap<String,NodeRef>();
    UserTransaction tx = transactionService.getUserTransaction();
    tx.begin();
    try {
      for (String folderName : folderPaths.keySet()) {
        ResultSet resultSet = null;
        try {
          resultSet = searchService.query(
            StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, 
            SearchService.LANGUAGE_XPATH, folderPaths.get(folderName));
          List<ChildAssociationRef> refs = resultSet.getChildAssocRefs();
          if (refs != null && refs.size() > 0) {
            NodeRef folderRef = refs.get(0).getChildRef();
            folderRefs.put(folderName, folderRef);
          } else {
            logger.warn("Could not map " + folderName + " to NodeRef");
          }
        } catch (Exception e) {
          logger.warn("Exception mapping " + folderName + " to NodeRef", e);
        } finally {
          if (resultSet != null) {
            try { resultSet.close(); }
            catch (Exception e) { /* Nothing */ }
          }
        }
      }
      tx.commit();
    } catch (Exception e) {
      tx.rollback();
      throw e;
    }
  }
  
  public void assign(NodeRef sourceRef) 
      throws InvalidWorkflowTransitionException {
    // compute the target owner and folder
    String targetOwner = null;
    NodeRef targetFolder = null;
    Map<QName,Serializable> props = nodeService.getProperties(sourceRef);
    String pubState = getPubState(props.get(MyContentModel.PROP_PUBSTATE));
    if (MyContentModel.PUBSTATE_DRAFT.equals(pubState)) {
      targetOwner = (String) props.get(ContentModel.PROP_OWNER);
      targetFolder = getUserHomeFolder(targetOwner);
    } else if (MyContentModel.PUBSTATE_REVIEW.equals(pubState)) {
      targetOwner = "doc"; // single editor
      targetFolder = folderRefs.get("Review");
    } else if (MyContentModel.PUBSTATE_PUBLISHED.equals(pubState)) {
      targetOwner = "doc"; // single editor
      targetFolder = getPublishTargetFolder(
        (Date) props.get(MyContentModel.PROP_PUBDTTM),
        (Date) props.get(MyContentModel.PROP_UNPUBDTTM));
    }
    // since the action is triggered for all updates, we want to check
    // if the current folder is the same as the target folder, in which
    // case the operation is a NO-OP
    ChildAssociationRef parentRef = nodeService.getPrimaryParent(sourceRef);
    NodeRef currentFolder = parentRef.getParentRef();
    if (targetFolder != null && targetFolder.equals(currentFolder)) {
      return;
    }
    // validate the computed values, if they are null, then there
    // may be some problems in the configuration
    if (targetOwner == null) {
      throw new InvalidWorkflowTransitionException(
        "Invalid target owner: " + targetOwner);
    }
    if (targetFolder == null) {
      throw new InvalidWorkflowTransitionException(
        "Invalid Target folder" + targetFolder);
    }
    // check that the current user is authorized to do this transition
    String currentUser = authenticationService.getCurrentUserName();
    if (! isAuthorized(currentUser, targetFolder)) {
      throw new InvalidWorkflowTransitionException(
        "User " + currentUser + " is not authorized to perform this action");
    }
    String name = (String) props.get(ContentModel.PROP_NAME);
    changeOwner(sourceRef, targetOwner);
    moveNode(sourceRef, name, targetFolder);
  }

  public void move(NodeRef nodeRef, String targetFolderName) 
      throws InvalidWorkflowTransitionException {
    Map<QName,Serializable> props = nodeService.getProperties(nodeRef);
    String name = (String) props.get(ContentModel.PROP_NAME);
    NodeRef targetFolder = folderRefs.get(targetFolderName);
    moveNode(nodeRef, name, targetFolder);
  }
  
  private boolean isAuthorized(String currentUser, NodeRef targetFolderRef) {
    return ((permissionService.hasPermission(
      targetFolderRef, "Collaborator") == AccessStatus.ALLOWED) ||
      (permissionService.hasPermission(
      targetFolderRef, "Contributor") == AccessStatus.ALLOWED));
  }

  private NodeRef moveNode(
      NodeRef sourceRef, String sourceName, NodeRef targetFolder) {
    logger.info("Moving node: " + sourceRef + " to " + targetFolder);
    ChildAssociationRef childAssociationRef = 
      nodeService.moveNode(sourceRef, targetFolder, 
      ContentModel.ASSOC_CONTAINS,
      QName.createQName(MyContentModel.NAMESPACE_MYCOMPANY_CONTENT_MODEL,
      sourceName));
    return childAssociationRef.getChildRef();
  }

  private void changeOwner(NodeRef sourceRef, String targetOwner) {
    logger.info("Changing owner of node: " + sourceRef + " to " + targetOwner);
    nodeService.setProperty(
      sourceRef, ContentModel.PROP_OWNER, targetOwner);
  }

  private NodeRef getPublishTargetFolder(Date pubDate, Date unpubDate) {
    // if both are null, then go to Live
    if (pubDate == null && unpubDate == null) {
      return folderRefs.get("Live");
    }
    // if pubDate != null, then go to scheduled
    if (pubDate != null) {
      return folderRefs.get("Scheduled");
    }
    return null;
  }

  @SuppressWarnings("unchecked")
  private String getPubState(Serializable sourceStateProp) {
    Collection<String> sourcePubStates = (Collection<String>) sourceStateProp;
    if (! CollectionUtils.isEmpty(sourcePubStates)) {
      List<String> srcStates = new ArrayList<String>();
      srcStates.addAll(sourcePubStates);
      return srcStates.get(0);
    }
    return null;
  }

  private NodeRef getUserHomeFolder(String userName) {
    NodeRef personRef = personService.getPerson(userName);
    if (personRef != null) {
      NodeRef homeFolder = (NodeRef) nodeService.getProperty(
        personRef, ContentModel.PROP_HOMEFOLDER);
      return homeFolder;
    }
    return null;
  }
}

I also have a custom exception class defined below. I thought this would be useful, but it turns out that it doesn't make a difference. I include it here for completeness, it would have been perfectly acceptable to throw a java.lang.Exception instead.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Source: src/java/com/mycompany/alfresco/extension/workflow/InvalidWorkflowTransitionException.java
package com.mycompany.alfresco.extension.workflow;

/**
 * Exception thrown if the transition is not valid.
 */
public class InvalidWorkflowTransitionException extends Exception {

  private static final long serialVersionUID = -4722792549608835483L;

  public InvalidWorkflowTransitionException() {
    super();
  }
  
  public InvalidWorkflowTransitionException(String message) {
    super(message);
  }
  
  public InvalidWorkflowTransitionException(String message, Throwable t) {
    super(message, t);
  }
}

Spring Configuration

I decided to put all my action configuration in a single extension *-context.xml file. I show here the relevant snippet. If you want to try out the code yourself, stick the snippets from the three "modules" together.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?xml version="1.0" encoding="UTF-8"?>
<!-- Source: config/alfresco/extension/mycompany-behaviour-context.xml -->
<!DOCTYPE beans PUBLIC '-//SPRING//DTD BEAN//EN' 
    'http://www.springframework.org/dtd/spring-beans.dtd'>
<beans>

  <bean id="extension.actionResourceBundles"
      parent="actionResourceBundles">
    <property name="resourceBundles">
      <list>
        <value>alfresco.extension.mycompany-actions</value>
      </list>
    </property>
  </bean>

  <!-- workflow actions -->
  
  <bean id="send-to-review" 
      class="com.mycompany.alfresco.extension.workflow.SendToReviewAction" 
      parent="action-executer">
    <property name="helper" ref="workflowHelper"/>
  </bean>

  <bean id="send-to-draft" 
      class="com.mycompany.alfresco.extension.workflow.SendToDraftAction"
      parent="action-executer">
    <property name="helper" ref="workflowHelper"/>
  </bean>
  
  <bean id="publish-document"
      class="com.mycompany.alfresco.extension.workflow.PublishDocumentAction"
      parent="action-executer">
    <property name="helper" ref="workflowHelper"/>
  </bean>
  
  <bean id="unpublish-document"
      class="com.mycompany.alfresco.extension.workflow.UnpublishDocumentAction"
      parent="action-executer">
    <property name="helper" ref="workflowHelper"/>
  </bean>
  
  <bean id="workflowHelper"
      class="com.mycompany.alfresco.extension.workflow.WorkflowHelper"
      init-method="init">
    <property name="authenticationService" ref="authenticationService"/>
    <property name="transactionService" ref="transactionService"/>
    <property name="searchService" ref="searchService"/>
    <property name="nodeService" ref="nodeService"/>
    <property name="personService" ref="personService"/>
    <property name="permissionService" ref="permissionService"/>
    <property name="folderPaths">
      <map>
        <entry key="Review">
          <value>/app:company_home/cm:Public/cm:Review</value>
        </entry>
        <entry key="Scheduled">
          <value>/app:company_home/cm:Public/cm:Published/cm:Scheduled</value>
        </entry>
        <entry key="Archived">
          <value>/app:company_home/cm:Public/cm:Published/cm:Archived</value>
        </entry>
        <entry key="Live">
          <value>/app:company_home/cm:Public/cm:Published/cm:Live</value>
        </entry>
      </map>
    </property>
  </bean>

  <!-- continued below... -->

And here is the relevant section of the mycompany-actions.properties file. This just specifies the name and description assigned to the action NAME key.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Source: config/alfresco/extension/mycompany-actions.properties

# SendToReviewAction
send-to-review.title=Send to Review [mycompany]
send-to-review.description=Send to Review [mycompany]

# SendToDraftAction
send-to-draft.title=Send to Draft [mycompany]
send-to-draft.description=Send to Draft [mycompany]

# PublishDocumentAction
publish-document.title=Publish Document [mycompany]
publish-document.description=Publish Document [mycompany]

# UnpublishDocumentAction
unpublish-document.title=Unpublish Document [mycompany]
unpublish-document.description=Unpublish Document [mycompany]

# continued below...

Scheduler Actions

The Drupal Scheduler Module allows nodes to be published and unpublished on specific dates. I achieve this using two date fields (specifying the scheduled publish and unpublish dates) in the my:publishable aspect. We have used these fields in our workflow actions to determine whether to move a node from Review to Live or to Scheduled.

I initially wrote a parameterized custom action that is passed in a target folder, and tried wiring it up with prebuilt Alfresco components as described in the Alfresco Scheduled Actions Wiki Page, but could not make it work. Not sure what I did wrong (I did set up the reference to templateActionDefinition in case you were wondering), but it just wouldn't move the node at the scheduled time.

So I peeked at how the Alfresco built-in scheduled actions were wired up (in scheduled-jobs-context.xml) and discovered that they use a much more direct mechanism similar to that described in the Spring-Quartz Integration Page. There are slight differences - the Alfresco setup wires the scheduler into the Trigger and not the JobDetail using the Alfresco CronTriggerBean. In any case, this meant that I would need to write my own QuartzBean subclass to do the search to find the nodes that needed moving, and call my custom action with the target folder value.

The flow is like this. There is a Scheduled Publish Job (a subclass of QuartzBean) that is responsible for finding the nodes to apply the custom action on. This is wrapped by the Spring JobDetailBean. The JobDetailBean is called by a Trigger at appropriate times set in the cron expression. When called, the JobDetailBean calls the Job to find the nodes that need to be moved, then for each node found, uses Alfresco's Action Service to invoke the the custom action on it.

scheduled-publish

Although the action name is scheduled-publish, it is parameterized, so it can be used for Scheduled Publishing or Archiving, depending on the target folder parameter. It delegates to the WorkflowHelper's move() method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Source: src/java/com/mycompany/alfresco/extension/scheduler/ScheduledPublishAction.java
package com.mycompany.alfresco.extension.scheduler;

import java.util.List;

import org.alfresco.repo.action.ParameterDefinitionImpl;
import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.dictionary.DataTypeDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

import com.mycompany.alfresco.extension.workflow.InvalidWorkflowTransitionException;
import com.mycompany.alfresco.extension.workflow.WorkflowHelper;

/**
 * Action to schedule publishing and unpublishing of documents to and
 * from the Live folder. Called from Quartz. Parameters are set in code.
 */
public class ScheduledPublishAction extends ActionExecuterAbstractBase {

  public static final String NAME = "scheduled-mover";
  
  protected static final String PARAM_TARGETFOLDER = "target-folder";
  
  private WorkflowHelper workflowHelper;
  
  public void setWorkflowHelper(WorkflowHelper workflowHelper) {
    this.workflowHelper = workflowHelper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
    paramList.add(new ParameterDefinitionImpl(
      PARAM_TARGETFOLDER,
      DataTypeDefinition.TEXT,
      true,
      getParamDisplayLabel(PARAM_TARGETFOLDER)
    ));
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      String targetFolderName = 
        (String) action.getParameterValue(PARAM_TARGETFOLDER);
      workflowHelper.move(actionedUponNodeRef, targetFolderName);
    } catch (InvalidWorkflowTransitionException e) {
      throw new RuntimeException(e);
    }
  }
}

Scheduled Publish Job

The scheduled publish job finds the nodes for which the above action needs to be invoked. There are two instances of this job in the configuration, one for scheduled publish and one for scheduled archive. The query to run and the target folder to move to are set appropriately for each instance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// Source: src/java/com/mycompany/alfresco/extension/scheduler/ScheduledPublishJob.java
package com.mycompany.alfresco.extension.scheduler;

import javax.transaction.UserTransaction;

import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ActionService;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.transaction.TransactionService;
import org.apache.log4j.Logger;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;

/**
 * This class is scheduled via Quartz. This class calls the underlying
 * action for all the nodes that match a specified search query, and
 * applies the named action using ActionService on each such node.
 */
public class ScheduledPublishJob extends QuartzJobBean {

  private final Logger logger = Logger.getLogger(getClass());
  
  private TransactionService transactionService;
  private SearchService searchService;
  private ActionService actionService;
  private String query;
  private String targetFolder;
  private String actionName;
  
  public void setTransactionService(TransactionService transactionService) {
    this.transactionService = transactionService;
  }
  
  public void setSearchService(SearchService searchService) {
    this.searchService = searchService;
  }

  public void setActionService(ActionService actionService) {
    this.actionService = actionService;
  }

  public void setQuery(String query) {
    this.query = query;
  }

  public void setTargetFolder(String targetFolder) {
    this.targetFolder = targetFolder;
  }

  public void setActionName(String actionName) {
    this.actionName = actionName;
  }

  @Override
  protected void executeInternal(JobExecutionContext ctx)
      throws JobExecutionException {
    ResultSet resultSet = null;
    try {
      UserTransaction tx = transactionService.getUserTransaction();
      tx.begin();
      try {
        logger.debug("Querying store: " + query + "...");
        resultSet = searchService.query(
            StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, 
            SearchService.LANGUAGE_LUCENE, query);
        for (ChildAssociationRef caRef : resultSet.getChildAssocRefs()) {
          NodeRef nodeRef = caRef.getChildRef();
          Action action = actionService.createAction(actionName);
          action.setParameterValue(
              ScheduledPublishAction.PARAM_TARGETFOLDER, targetFolder);
          logger.debug("Moving scheduled node: " + nodeRef + 
              " to " + targetFolder);
          actionService.executeAction(action, nodeRef);
        }
        tx.commit();
      } catch (Exception e) {
        tx.rollback();
        throw e;
      } finally {
        if (resultSet != null) { resultSet.close(); }
      }
    } catch (Exception e) {
      throw new JobExecutionException(e);
    }
  }
}

Spring Configuration

The following code snippet represents the Spring configuration for the scheduler portion. Notice how much simpler this is compared to the configuration in the Scheduled Action Wiki Page.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!-- Source: config/alfresco/extension/mycompany-behaviour-context.xml -->

  <!-- ...continued from above -->

  <!-- scheduled actions -->
  
  <bean id="scheduled-publish" 
      class="com.mycompany.alfresco.extension.scheduler.ScheduledPublishAction" 
      parent="action-executer">
    <property name="workflowHelper" ref="workflowHelper"/>
  </bean>
  
  <bean name="scheduledPublishJob" 
      class="org.springframework.scheduling.quartz.JobDetailBean">
    <property name="jobClass" 
      value="com.mycompany.alfresco.extension.scheduler.ScheduledPublishJob"/>
    <property name="jobDataAsMap">
      <map>
        <entry key="transactionService">
          <ref bean="transactionService"/>
        </entry>
        <entry key="searchService">
          <ref bean="searchService"/>
        </entry>
        <entry key="actionService">
          <ref bean="actionService"/>
        </entry>
        <entry key="query">
          <value>
          +PATH:"/app:company_home/cm:Public/cm:Published/cm:Scheduled/*"
          +@my\:pubDttm:[1970\-01\-01T00:00:00 TO NOW]
          </value>
        </entry>
        <entry key="actionName" value="scheduled-publish"/>
        <entry key="targetFolder" value="Live"/>        
      </map>
    </property>
  </bean>
  
  <bean id="scheduledPublishTrigger" class="org.alfresco.util.CronTriggerBean">
    <property name="jobDetail" ref="scheduledPublishJob"/>
    <property name="scheduler" ref="schedulerFactory"/>
    <property name="cronExpression" value="0 0/15 * * * ?"/>
  </bean>

  <bean name="scheduledArchiveJob" 
      class="org.springframework.scheduling.quartz.JobDetailBean">
    <property name="jobClass" 
      value="com.mycompany.alfresco.extension.scheduler.ScheduledPublishJob"/>
    <property name="jobDataAsMap">
      <map>
        <entry key="transactionService">
          <ref bean="transactionService"/>
        </entry>
        <entry key="searchService">
          <ref bean="searchService"/>
        </entry>
        <entry key="actionService">
          <ref bean="actionService"/>
        </entry>
        <entry key="query">
          <value>
          +PATH:"/app:company_home/cm:Public/cm:Published/cm:Live/*"
          +@my\:unpubDttm:[1970\-01\-01T00:00:00 TO NOW]
          </value>
        </entry>
        <entry key="actionName" value="scheduled-publish"/>
        <entry key="targetFolder" value="Archived"/>        
      </map>
    </property>
  </bean>
  
  <bean id="scheduledArchiveTrigger" class="org.alfresco.util.CronTriggerBean">
    <property name="jobDetail" ref="scheduledArchiveJob"/>
    <property name="scheduler" ref="schedulerFactory"/>
    <property name="cronExpression" value="0 0/15 * * * ?"/>
  </bean>

  <!-- continued below... -->

As before, the titles and descriptions are set up in the properties file.

1
2
3
4
5
6
7
8
9
# Source: config/alfresco/extension/mycompany-actions.properties

# ...continued from above

# Scheduled Publish/Unpublish
scheduled-mover.title=Scheduled Publish/Unpublish [mycompany]
scheduled-mover.description=Scheduled Publish/Unpublish [mycompany]

# continued below...

Notice that unlike the Workflow Actions, here there is only a single action that does two different things. This is because the action is not fired by a content rule, so the Job code can create the action, configure it based on which one (publish or archive) it is, and fires the configured action.

External Publisher Actions

The External Publish Actions correspond to a Drupal module I wrote that sends out an XMLRPC request to an external Java publisher on publish and unpublish events. The XMLRPC request parameter is a Map of node properties. One of the things it does in the Java publisher is write out the node as a JSON flat file. The thinking was that since reads outnumber writes by multiple orders of magnitude, it is cheaper to publish out to flat files and use that to render the page, rather than have to query the CMS on every read. XMLRPC was chosen because it is language-neutral and because Drupal supported XMLRPC writing (and reading).

The External Publish Actions are modeled as Inbound and Outbound rules on the Public/Published/Live space. Every time content moves into the Live space, an XMLRPC publish request is sent to the external publisher, and every time it moves out of the Live space, an unpublish request is sent. The actions are described below:

Name Configured on Space Recursive? Rule Type Description
send-external-publish Public/Published/Live No INBOUND Invoked every time a Node moves into the Live folder.
send-external-unpublish Public/Published/Live No OUTBOUND Invoked every time a Node moves out of the Live folder.

Like the Workflow group of Actions, the actions are set up on the Live space via content rules, and are parameterless because they need to trigger without user intervention. So all they do is to delegate to the ExtPubHelper class with the appropriate parameters.

send-external-publish

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/extpub/ExternalPublishAction.java
package com.mycompany.alfresco.extension.extpub;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Send Publish XMLRPC request to external publisher.
 * Configured on: Public/Published/Live
 * As: INBOUND rule.
 * Condition: type == my:PublishableDoc and subtypes.
 */
public class ExternalPublishAction extends ActionExecuterAbstractBase {

  public static final String NAME = "send-external-publish";
  
  private ExtPubHelper helper;
  
  public void setHelper(ExtPubHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.sendMessage(actionedUponNodeRef, "publisher.publish");
    } catch (Exception e) {
      throw new RuntimeException("Publish failed, notify administrator", e);
    }
  }
}

send-external-unpublish

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Source: src/java/com/mycompany/alfresco/extension/extpub/ExternalUnpublishAction.java
package com.mycompany.alfresco.extension.extpub;

import java.util.List;

import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.repository.NodeRef;

/**
 * Send Unpiblish XMLRPC request to external publisher.
 * Configured on: Public/Published/Live
 * As: OUTBOUND rule.
 * Condition: type == my:PublishableDoc and subtypes.
 */
public class ExternalUnpublishAction extends ActionExecuterAbstractBase {

  public static final String NAME = "send-external-unpublish";
  
  private ExtPubHelper helper;
  
  public void setHelper(ExtPubHelper helper) {
    this.helper = helper;
  }

  @Override
  protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
  }

  @Override
  protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
    try {
      helper.sendMessage(actionedUponNodeRef, "publisher.unpublish");
    } catch (Exception e) {
      throw new RuntimeException("Unpublish failed, notify administrator", e);
    }
  }
}

ExtPubHelper

This is where the real work of converting the NodeRef to a Map of properties and sending it off to the external publisher happens. I do a bit of post-processing on the node properties to replace the key with the friendly prefix here.

Alfresco already has the Redstone XMLRPC Library in its classpath, so I used that here. The code for the helper class is shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// Source: src/java/com/mycompany/alfresco/extension/extpub/ExtPubHelper.java
package com.mycompany.alfresco.extension.extpub;

import java.io.Serializable;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import marquee.xmlrpc.XmlRpcClient;
import marquee.xmlrpc.XmlRpcException;

import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;

/**
 * Service to package up the current NodeReference and send
 * the XMLRPC request to the external publisher. An exception
 * will be thrown if the request failed.
 */
public class ExtPubHelper {

  private final Logger logger = Logger.getLogger(getClass());
  
  private NodeService nodeService;
  private NamespaceService namespaceService;
  private String serviceUrl;

  private XmlRpcClient client;
  
  public void setNodeService(NodeService nodeService) {
    this.nodeService = nodeService;
  }

  public void setNamespaceService(NamespaceService namespaceService) {
    this.namespaceService = namespaceService;
  }

  public void setServiceUrl(String serviceUrl) {
    this.serviceUrl = serviceUrl;
  }

  protected void init() throws Exception {
    logger.debug("Creating instance of XMLRPC client...");
    this.client = new XmlRpcClient(new URL(serviceUrl));
  }
  
  public void sendMessage(NodeRef nodeRef, String method) 
      throws XmlRpcException {
    Map<String,Serializable> props =
      remapKeys(nodeService.getProperties(nodeRef));
    logger.debug("Sending XMLRPC request " + method +
        " for " + nodeRef + " to " + serviceUrl);
    client.invoke(method, new Object[] {props});
  }
  
  private Map<String,Serializable> remapKeys(Map<QName,Serializable> props) {
    Map<String,Serializable> sprops = new HashMap<String,Serializable>();
    for (QName key : props.keySet()) {
      List<String> namespaces = new ArrayList<String>();
      namespaces.addAll(namespaceService.getPrefixes(key.getNamespaceURI()));
      if (namespaces.size() == 1) { // this should always be true, but JIC
        String skey = StringUtils.join(new String[] {
          namespaces.get(0), key.getLocalName()}, ":");
        sprops.put(skey, props.get(key));
      }
    }
    return sprops;
  }
}

Spring Configuration

The Spring configuration is similar to the Workflow action configuration, so no surprises here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- Source: config/alfresco/extension/mycompany-behaviour-context.xml -->

  <!-- ...continued from above -->

  <!-- extpub actions -->
  
  <bean id="send-external-publish"
      class="com.mycompany.alfresco.extension.extpub.ExternalPublishAction"
      parent="action-executer">
    <property name="helper" ref="extPubHelper"/>
  </bean>
  
  <bean id="send-external-unpublish"
      class="com.mycompany.alfresco.extension.extpub.ExternalUnpublishAction"
      parent="action-executer">
    <property name="helper" ref="extPubHelper"/>
  </bean>
  
  <bean id="extPubHelper" 
      class="com.mycompany.alfresco.extension.extpub.ExtPubHelper"
      init-method="init">
    <property name="nodeService" ref="nodeService"/>
    <property name="namespaceService" ref="namespaceService"/>
    <property name="serviceUrl" value="http://localhost:9007"/>
  </bean>
  
</beans>

And the title and descriptions set in mycompany-actions.properties file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Source: config/alfresco/extension/mycompany-actions.properties

# ...continued from above

# External Publish
send-external-publish.title=Send XMLRPC Publish [mycompany]
send-external-publish.description=Send XMLRPC Publish [mycompany]

# External Unpublish
send-external-unpublish.title=Send XMLRPC Unpublish [mycompany]
send-external-unpublish.description=Send XMLRPC Unublish [mycompany]

Conclusion

So there you have it. Its been a long post, thanks for staying with me this far, hopefully it has been useful. Despite the length, though, my overall impression is that setting up the workflow has been easier to do in Alfresco than it was in Drupal.

I think one reason for this seeming paradox is that there is a fundamental design difference between Alfresco and Drupal. Using the Drupal point-and-click interface to produce customizations that approximate what you want is very easy, but customizing at the code level is much harder. On the other hand, Alfresco's web interface is not quite that intuitive, and much of the customizations that Drupal-ites take for granted is available only through XML (Alfresco and Spring) configuration, but the Alfresco Java API is much more mature, complete and easier to extend.

Another powerful concept in Alfresco that makes things really simple is the concept of spaces. In my case, figuring out the current state transition is easy because you can figure out the source state from the current location of the node. This leads to code that is much simpler to write (and read) than Drupal code to do the same thing. Its almost like an extra dimension has been handed to the programmer to play with.

So with Alfresco, if you are willing to write some Java code, the world (at least my limited Alfresco world) is your oyster. Of course, this opinion is probably predicated on the fact that I am better at Java than at PHP. Also, since I am by no means an Alfresco expert, I am not sure if what I have in here represent best practices or not - if you notice something that isn't or something obviously wrong, will appreciate knowing.