Showing posts with label cms. Show all posts
Showing posts with label cms. Show all posts

Thursday, October 21, 2010

A Custom Drupal XMLRPC Service

I have written earlier about using Drupal as an XMLRPC client - via a custom module that hooks into the persistence lifecycle of a Drupal node in order to send XMLRPC requests out to an external publishing system containing an embedded XMLRPC server. Drupal can also act as an XMLRPC server, receiving and acting on XMLRPC requests from external clients.

We have been using one of the built-in services (the comment.save from Comment Services Module). However, we recently decided to build an external Comment Moderation tool, since we have outgrown the rather rudimentary comment moderation form available in Drupal, and that needs services exposed on Drupal to publish, unpublish and delete a comment, which are not available from the comment services module.

My initial explorations on Google pointed me to the this post on the Riff Blog, and thence to the XMLRPC hook, available in core Drupal. However, the results were not too satisfying (it didn't show up on the Services page at /admin/build/services) so I quickly abandoned this path and went looking for something better.

I found my answer in the source code for the Comment Services Module - it implemented hook_service(), so that pointed me to the Services Module, which in turn led me to the Services Handbook, and buried in the links on the right navigation toolbar on this page, some information that I could actually use.

Interestingly, not only does Drupal allow you to build/install custom services, it also allows for custom servers (such as JSON or SOAP). However, since I already had an XMLRPC server installed for the pre-installed services, I did not explore this option. There is more information about this on Deja Augustine's post here, along with some skeletal code for a simple service.

My example is a bit more involved, but uses the same ideas as Deja's post. Build a custom module that is in the "Services - services" package and depends on the services package (to the best of my knowledge, these are required, when I tried putting it under a different package, it would not show up on the Services page). Therefore, although I physically put the code under the sites/all/modules/custom/cmxs directory, the package name is "Services - services". Here is the .info file for my module.

1
2
3
4
5
name = cmxs
description = Comment Moderation XMLRPC Services
package = Services - services
dependencies[] = services
core = 6.x

The module code is also quite simple. The hook_service() declares the methods that the service makes available, along with name, input and output parameter name and types, and the names of callback functions each method must call. You can install the module immediately after your hook_service() declarations are done (along with stubs for the callback functions) by going to the Modules (admin/build/modules) page and enabling the new module. The new services will show up on the Services (admin/build/services) page, along with forms to manually test the services.

Here is the complete code for my services module:

  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
<?php

define('CMXS_SUCCESS', 0);
define('CMXS_COMMENT_PUBLISHED', 0);
define('CMXS_COMMENT_NOT_PUBLISHED', 1);

/**
 * Implementation of hook_service().
 * Describes the methods that are exposed by this service module.
 */
function cmxs_service() {
  return array (
    // comment_publish
    array (
      '#method' => 'cmxs.comment_publish',
      '#callback' => 'cmxs_comment_publish',
      '#access callback' => 'cmxs_user_access',
      '#args' => array (
        array (
          '#name' => 'cids',
          '#type' => 'string',
          '#description' => t('Comma-separated list of Comment IDs, eg. $cid1,$cid2,...')
        )
      ),
      '#return' => 'int',
      '#help' => t('Publishes the specified comment.')
    ),
    // comment_unpublish
    array (
      '#method' => 'cmxs.comment_unpublish',
      '#callback' => 'cmxs_comment_unpublish',
      '#access callback' => 'cmxs_user_access',
      '#args' => array (
        array (
          '#name' => 'cids',
          '#type' => 'string',
          '#description' => t('Comma-separated list of Comment IDs, eg. $cid1,$cid2,...')
        )
      ),
      '#return' => 'int',
      '#help' => t('Unpublishes the specified comment.')
    ),
    // comment_delete
    array (
      '#method' => 'cmxs.comment_delete',
      '#callback' => 'cmxs_comment_delete',
      '#access callback' => 'cmxs_user_access',
      '#args' => array (
        array (
          '#name' => 'cids',
          '#type' => 'string',
          '#description' => t('Comma-separated list of Comment IDs, eg. $cid1,$cid2,...')
        )
      ),
      '#return' => 'int',
      '#help' => t('Deletes the specified comment.')
    ),
  );
}

/**
 * Implementation of hook_disable().
 * Actions that need to happen when this module is disabled.
 */
function cmxs_disable() {
  cache_clear_all('services:methods', 'cache');
}

/**
 * Implementation of hook_enable().
 * Actions that need to happen when this module is enabled.
 */
function cmxs_enable() {
  cache_clear_all('services:methods', 'cache');
}

/**
 * Custom user access function to short circuit user_access() since
 * we want to bypass Drupal's authentication, since the tool will
 * always send authenticated requests.
 */
function cmxs_user_access() {
  return TRUE;
}

/**
 * Finds the comments corresponding to the cids specified that are in
 * state NOT_PUBLISHED and updates their status to PUBLISHED, then saves 
 * them.
 */
function cmxs_comment_publish($cids) {
  watchdog('cmxs', 'cmxs_comment_publish(cids=' . $cids . ')');
  $comments = _cmxs_find_comments($cids, CMXS_COMMENT_NOT_PUBLISHED);
  foreach ($comments as $comment) {
    $comment->status = CMXS_COMMENT_PUBLISHED;
    comment_save((array) $comment);
    watchdog('cmxs', 'Comment (cid=' . $comment->cid . ') published');
  }
  return CMXS_SUCCESS;
}

/**
 * Finds the comments corresponding to the cids specified that are in
 * state PUBLISHED and updates their status to PUBLISHED, then saves them.
 */
function cmxs_comment_unpublish($cids) {
  watchdog('cmxs', 'cmxs_comment_unpublish(cids=' . $cids . ')');
  $comments = _cmxs_find_comments($cids, CMXS_COMMENT_PUBLISHED);
  foreach ($comments as $comment) {
    $comment->status = CMXS_COMMENT_NOT_PUBLISHED;
    comment_save((array) $comment);
    watchdog('cmxs', 'Comment (cid=' . $comment->cid . ') unpublished');
  }
  return CMXS_SUCCESS;
}

/**
 * Since deleting a comment requires administrator privileges, we cannot
 * call comment_delete($cid) directly (since our service has no privileges).
 * So we unpublish first, and then delete using a direct SQL call.
 */
function cmxs_comment_delete($cids) {
  watchdog('cmxs', 'cmxs_comment_delete(cids=' . $cids . ')');
  $comments = _cmxs_find_comments($cids);
  foreach ($comments as $comment) {
    $comment->status = CMXS_COMMENT_NOT_PUBLISHED;
    comment_save((array) $comment); // UDXI unpublished called here
    _cmxs_delete_comment($comment->cid);
    watchdog('cmxs', 'Comment (cid=' . $comment->cid . ') deleted');
  }
  return CMXS_SUCCESS;
}

/**
 * Given a comma-separated list of CIDs, return a list of comments that
 * correspond to these CIDs. CIDs that don't correspond to a Comment in
 * Drupal are silently ignored. If the optional parameter status is provided
 * only comments with the specified status are returned.
 */
function _cmxs_find_comments($cids, $status = NULL) {
  $comments = array();
  $cid_array = explode(',', $cids);
  if ($cid_array == FALSE) {
    return $comments;
  } else {
    foreach ($cid_array as $cid) {
      $comment = _comment_load($cid);
      if ($comment != NULL) {
        if ($status != NULL) {
          if ($comment->status != $status) {
            continue;
          }
        }
        $comments[] = $comment;
      }
    }
  }
  return $comments;
}

/**
 * Deletes a comment corresponding to the specified cid from the Drupal
 * database. There is no authorization check.
 */
function _cmxs_delete_comment($cid)  {
  $db_result = db_query('DELETE FROM {comments} WHERE cid = %d', $cid);
}

As you can see, the hook_service() is purely declarative. I also have hook_enable() and hook_disable() implementations to make it a bit quicker to develop (changes in method signature only needs a module disable followed by a module enable, no update.php run required).

I also have a custom user_access() function which does nothing. Because I am using non-authenticated XMLRPC requests, and the Drupal comment API for saving the updated comment object, I figured that Drupal would insist (as it should) on an authorized user to do the updates. So the cmxs_user_access() function is there to bypass Drupal's authentication.

I have mentioned before about how impressed I am by Drupal's overall design, and the Services module is no exception. Like the rest of Drupal, it follows the convention over configuration philosophy, and once you understand the convention, building a custom service is really quite simple.

Friday, July 02, 2010

Alfresco: Exposing Functionality via Webscripts

As I have mentioned before, I don't plan on my (hypothetical) bloggers and editors using the Alfresco web UI to manage the content in the CMS. I want to provide them with an interface similar to Drupal's rather than the file based interface Alfresco provides by default. To modify Alfresco's web UI to support this would require quite a lot of work, including having to learn Java Server Faces, upon which Alfresco's web UI is based, and which I know nothing about, and given that I don't use it at the moment for anything else, don't feel particularly compelled to learn.

Instead, I plan on building a Java based webapp from the ground up, and have it proxy through to Alfresco over HTTP via REST scripts using Alfreso's Webscript framework. This approach appears to be quite popular, Alfresco Developer's Guide has an entire chapter devoted to this, and there is also a Alfresco's Web Scripts page also has quite a bit of documentation about this.

Essentially, on the Alfresco side, one writes a "controller", consisting of an XML descriptor, a Webscript and one or more views to expose a given function. Each view has a URL associated with it. Webscripts can be written using Java or Javascript. Views are written using Freemarker Template Language (FTL). There is a strict naming convention that needs to be followed for everything to work, since the controller is run dynamically using the Webscript framework.

I made a conscious decision to keep things in Java as far as possible, so even though I liked the Javascript API (it is as comprehensive as the Java API, results in more concise code and you can write and debug code in the running server, which is a great timesaver), I decided to check out the Java way first. I do want to use the Javascript API (at least for Webscripts) at some point in the future though.

So anyway, I built two Webscripts this week, which I describe below.

Search Webscript

The Search Webscript does a fulltext search for a given term. It is intended to be used from a in-page search widget, like the ones you find on the top right corner of some websites. The user enters a term or phrase and gets back a page of search results.

Obviously, the Search Webscript uses the Alfresco Search Service to retrieve the results using a Lucene query. The difference is that the results are filtered by user role, ie, a blogger only gets to search in documents that are either in his inbox, or have been submitted by him for review, or have been published already. An editor on the other hand, gets to search in all documents in review, and all published documents, but not documents that are in Draft state in blogger's inboxes.

Descriptor

Here is the XML descriptor for the Search WebScript. It needs to be stored in the directory shown in the Source comment, along with the FTL files. It defines the URLs that are exposed by the controller, along with some transaction and authentication requirements.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<!-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/search/search.get.desc.xml -->
<webscript>
  <shortname>Document Search [mycompany]</shortname>
  <description>Document Search [mycompany]</description>
  <url>/mycompany/search</url>
  <url>/mycompany/search.json</url>
  <url>/mycompany/search.html</url>
  <format default="json">extension</format>
  <authentication>none</authentication>
  <transaction>none</transaction>
</webscript>

Java Code

A design strategy I have followed consistently in all my Alfresco work so far is to delegate the work involving the Alfresco Foundation API to a helper class. This is to make it easier to test functionality via unit tests on the command line (although it does take a while for the unit tests to run, since it has to spin up the Alfresco application context each time). Consequently, the Java code that follows isn't particularly interesting, but serves to show the general structure of a Webscript Java controller. I show the WebscriptHelper class later in the post.

 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
// Source: src/java/com/mycompany/alfresco/extension/webscripts/GetSearch.java
package com.mycompany.alfresco.extension.webscripts;

import java.util.HashMap;
import java.util.Map;

import org.alfresco.web.scripts.Cache;
import org.alfresco.web.scripts.DeclarativeWebScript;
import org.alfresco.web.scripts.Status;
import org.alfresco.web.scripts.WebScriptException;
import org.alfresco.web.scripts.WebScriptRequest;
import org.apache.commons.lang.StringUtils;

public class GetSearch extends DeclarativeWebScript {

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

  @Override
  public Map<String,Object> executeImpl(WebScriptRequest request, 
      Status status, Cache cache) {
    String query = request.getParameter("query");
    // TODO: replace with alf_ticket?
    String user = request.getParameter("user");
    if (StringUtils.isEmpty(user) || StringUtils.isEmpty(query)) {
      throw new WebScriptException(
        "Can't find mandatory parameters query term and/or user");
    }
    Map<String,Object> model = new HashMap<String,Object>();
    helper.search(model, query, user);
    model.put("query", query);
    model.put("user", user);
    return model;
  }
}

As you can see, my Webscript controller extends DeclarativeWebScript and overrides its executeImpl(WebRequest, Status, Cache)::Map method. The call to helper.search() populates a List<Post> into the model (the Map that is returned from the executeImpl).

HTML Template

The HTML template is quite simple, we don't really need this for my setup to work (I plan on using the JSON data on the client side), but it helps for debugging the service. Besides I wanted to check out FTL (I've used Velocity but not Freemarker before this). Here is the HTML template.

 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
<#-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/search/search.get.html.ftl -->
<html>
  <head>
    <title>Search results for "${query}"</title>
  </head>
  <body>
    <h2>Search Results for "${query}"</h2>
    <#list posts as post>
      <p>
        <#if post.pubState == "Draft" || post.pubState == "Review">
          <b><a href="/preview/${post.id}">${post.title}</a></b>
        </#if>
        <#if post.pubState == "Published">
          <b><a href="${post.furl}">${post.title}</a></b>
        </#if>
        <br/>${post.description?html}
        <br/><br/><font size="-1">
          <b>Status: </b>${post.pubState}&nbsp;
          <b>Created By: </b>${post.creator}&nbsp;
          <b>Dated: </b>${post.created?datetime}
        </font>
      </p>
    </#list>
  </body>
</html>

JSON Template

The corresponding JSON template looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<#-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/search/search.get.json.ftl -->
{"model": 
  {"query" : "${query}"},
  {"user" : "${user}"},
  {"posts" : [
    <#list posts as post>
      {"id" : "${post.id}",
        "title" : "${post.title}",
      <#if post.pubState == "Draft" || post.pubState == "Review">
        "furl" : "/preview/${post.id}",
      <#else>
        "furl" : "${post.furl}",
      </#if>
        "description" : ${post.description?html}",
        "pubState" : "${post.pubState}",
        "creator" : "${post.creator}",
        "created" : "${post.created?datetime}"
      },
    </#list>
  ]}
}

Example HTML Output

An example output for a search with user=happy and query=drupal is shown below. The JSON output is less spectacular (relatively speaking), but contains the same data. This is in response to the URL:

1
http://localhost:8080/alfresco/service/mycompany/search.html?query=drupal&user=happy


Dashboard Webscript

The Dashboard Webscript returns the documents that a user needs to work on at the current time. Like the Search Webscript, the contents of the dashboard is dependent on the user. For example, a blogger only sees the contents of his home directory, while an editor will see separate buckets containing lists of documents for Review, Published documents, etc.

Descriptor

The descriptor for this webscript is similar to the one for search. Here it is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<!-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/dashboard/dashboard.get.desc.xml -->
<webscript>
  <shortname>Dashboard [mycompany]</shortname>
  <description>Dashboard [mycompany]</description>
  <url>/mycompany/dashboard</url>
  <url>/mycompany/dashboard.json</url>
  <url>/mycompany/dashboard.html</url>
  <format default="json">extension</format>
  <authentication>none</authentication>
  <transaction>none</transaction>
</webscript>

Java Code

Again, not much to say about this, its similar to the one for Search.

 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
// Source: src/java/com/mycompany/alfresco/extension/webscripts/GetDashboard.java
package com.mycompany.alfresco.extension.webscripts;

import java.util.HashMap;
import java.util.Map;

import org.alfresco.web.scripts.Cache;
import org.alfresco.web.scripts.DeclarativeWebScript;
import org.alfresco.web.scripts.Status;
import org.alfresco.web.scripts.WebScriptException;
import org.alfresco.web.scripts.WebScriptRequest;
import org.apache.cxf.common.util.StringUtils;

public class GetDashboard extends DeclarativeWebScript {

  private WebscriptHelper helper;
  
  public void setHelper(WebscriptHelper helper) {
    this.helper = helper;
  }
  
  @Override
  public Map<String,Object> executeImpl(WebScriptRequest request, 
      Status status, Cache cache) {
    String user = request.getParameter("user");
    if (StringUtils.isEmpty(user)) {
      throw new WebScriptException("Can't find mandatory parameter user");
    }
    Map<String,Object> model = new HashMap<String,Object>();
    helper.getDashboard(model, user);
    model.put("user", user);
    return model;
  }
}

HTML Template

The HTML Template for the Dashboard 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
 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
<#-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/dashboard/dashboard.get.html.ftl -->
<html>
  <head>
    <title>Dashboard for ${role?cap_first} ${user}</title>
  </head>
  <body>
    <h2>Welcome ${role?cap_first} ${user}</h2>
    <#if inbox??>
      <h2>Inbox</h2>
      <table cellspacing="3" cellpadding="3" border="0">
        <tr>
          <td><b>Post Title</b></td>
          <td><b>Status</b></td>
          <td><b>Created By</b></td>
          <td><b>Date</b></td>
        </tr>
      <#list inbox as post>
        <tr>
          <td><a href="/preview/${post.id}">${post.title}</a></td>
          <td>${post.pubState}</td>
          <td>${post.creator}</td>
          <td>${post.created?datetime}</td>
        </tr>  
      </#list>
      </table>
    </#if>
    <#if review??>
      <h2>Posts Pending Review</h2>
      <table cellspacing="3" cellpadding="3" border="0">
        <tr>
          <td><b>Post Title</b></td>
          <td><b>Status</b></td>
          <td><b>Created By</b></td>
          <td><b>Date</b></td>
        </tr>
      <#list review as post>
        <tr>
          <td><a href="/preview/${post.id}">${post.title}</a></td>
          <td>${post.pubState}</td>
          <td>${post.creator}</td>
          <td>${post.created?datetime}</td>
        </tr>  
      </#list>
      </table>
    </#if>
    <#if published??>
      <h2>Published Posts</h2>
      <table cellspacing="3" cellpadding="3" border="0">
        <tr>
          <td><b>Post Title</b></td>
          <td><b>Status</b></td>
          <td><b>Created By</b></td>
          <td><b>Date</b></td>
        </tr>
      <#list published as post>
        <tr>
          <td><a href="${post.furl}">${post.title}</a></td>
          <td>${post.pubState}</td>
          <td>${post.creator}</td>
          <td>${post.created?datetime}</td>
        </tr>  
      </#list>
      </table>
    </#if>
    <#if scheduled??>
      <h2>Posts Scheduled for Publish</h2>
      <table cellspacing="3" cellpadding="3" border="0">
        <tr>
          <td><b>Post Title</b></td>
          <td><b>Status</b></td>
          <td><b>Created By</b></td>
          <td><b>Date</b></td>
        </tr>
      <#list scheduled as post>
        <tr>
          <td><a href="/preview/${post.id}">${post.title}</a></td>
          <td>${post.pubState}</td>
          <td>${post.creator}</td>
          <td>${post.created?datetime}</td>
        </tr>  
      </#list>
      </table>
    </#if>
    <#if archived??>
      <h2>Archived Posts</h2>
      <table cellspacing="3" cellpadding="3" border="0">
        <tr>
          <td><b>Post Title</b></td>
          <td><b>Status</b></td>
          <td><b>Created By</b></td>
          <td><b>Date</b></td>
        </tr>
      <#list archived as post>
        <tr>
          <td><a href="/preview/${post.id}">${post.title}</a></td>
          <td>${post.pubState}</td>
          <td>${post.creator}</td>
          <td>${post.created?datetime}</td>
        </tr>  
      </#list>
      </table>
    </#if>
  </body>
</html>

JSON Template

And the JSON Template for the Dashboard.

 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
<#-- Source: config/alfresco/extension/templates/webscripts/com/mycompany/dashboard/dashboard.get.json.ftl -->
{"model": 
  {"user" : "${user}"},
  {"role" : "${role?cap_first}"},
  <#if inbox??>
    {"inbox" : [
      <#list posts as post>
        {"id" : "${post.id}",
         "title" : "${post.title}",
         "furl" : "/preview/${post.id}",
         "pubState" : "${post.pubState}",
         "creator" : "${post.creator}",
         "created" : "${post.created?datetime}"
        },
      </#list>
    ]},
  </#if>
  <#if review??>
    {"review" : [
      <#list review as post>
        {"id" : "${post.id}",
         "title" : "${post.title}",
         "furl" : "/preview/${post.id}",
         "pubState" : "${post.pubState}",
         "creator" : "${post.creator}",
         "created" : "${post.created?datetime}"
        },
      </#list>
    ]},
  </#if>
  <#if published??>
    {"published" : [
      <#list published as post>
        {"id" : "${post.id}",
         "title" : "${post.title}",
         "furl" : "${post.furl}",
         "pubState" : "${post.pubState}",
         "creator" : "${post.creator}",
         "created" : "${post.created?datetime}"
        },
      </#list>
    ]},
  </#if>
  <#if scheduled??>
    {"scheduled" : [
      <#list scheduled as post>
        {"id" : "${post.id}",
         "title" : "${post.title}",
         "furl" : "/preview/${post.id}",
         "pubState" : "${post.pubState}",
         "creator" : "${post.creator}",
         "created" : "${post.created?datetime}"
        },
      </#list>
    ]},
  </#if>
  <#if archived??>
    {"archived" : [
      <#list archived as post>
        {"id" : "${post.id}",
         "title" : "${post.title}",
         "furl" : "/preview/${post.id}",
         "pubState" : "${post.pubState}",
         "creator" : "${post.creator}",
         "created" : "${post.created?datetime}"
        },
      </#list>
    ]},
  </#if>
}

Example HTML Output

The XML output representing the dashboard for user "happy" is shown below. This is in response to a URL of the form:

1
http://localhost:8080/alfresco/service/mycompany/dashboard.html?user=doc


Webscript Helper

The Webscript helper does most of the work, as I mentioned above. Not much explanation is required if you are familiar with the Alfresco Java API. It has two methods search() and getDashboard() which are called from the corresponding Webscripts.

  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
// Source: src/java/com/mycompany/alfresco/extension/webscripts/WebscriptHelper.java
package com.mycompany.alfresco.extension.webscripts;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;

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.AuthorityService;
import org.alfresco.service.namespace.QName;
import org.alfresco.service.transaction.TransactionService;
import org.alfresco.web.scripts.WebScriptException;
import org.apache.commons.lang.WordUtils;
import org.apache.log4j.Logger;
import org.springframework.util.CollectionUtils;

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

public class WebscriptHelper {

  private final Logger logger = Logger.getLogger(getClass());
  
  private AuthorityService authorityService;
  private TransactionService transactionService;
  private SearchService searchService;
  private NodeService nodeService;

  public void setAuthorityService(AuthorityService authorityService) {
    this.authorityService = authorityService;
  }

  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 search(Map<String,Object> model, 
      String term, String userName) throws WebScriptException {
    List<Post> posts = new ArrayList<Post>();
    ResultSet resultSet = null;
    UserTransaction tx = transactionService.getUserTransaction();
    try {
      tx.begin();
      try {
        // tailor the query depending on who is calling it
        StringBuilder queryBuilder = new StringBuilder();
        String role = getRole(userName);
        if ("blogger".equals(role)) {
          // Blogger can see own posts in Draft and Review, and all
          // published posts
          queryBuilder.
            // all docs with term in user's home directory
            append("(+PATH:\"/app:company_home/app:user_homes/sys:").
            append(WordUtils.capitalize(userName)).
            append("/*\" +").
            append(term).
            append(") ").
            // OR all docs in review containing term and created by user
            append("(+PATH:\"/app:company_home/cm:Public/cm:Review/*").
            append("\" +cm\\:creator:\"").
            append(userName).
            append("\" +").
            append(term).
            append(") ").
            // OR all docs in published containing term
            append("(+PATH:\"/app:company_home/cm:Public/cm:Published/cm:Live/*").
            append("\" +").
            append(term).
            append(")");
        } else if ("editor".equals(role)) {
          // Editor can see all posts in Review and Published
          queryBuilder.
            // all docs with term in Review
            append("(+PATH:\"/app:company_home/cm:Public/cm:Review/*").
            append("\" +").
            append(term).
            append(") ").
            // all docs with term in Published
            append("(+PATH:\"/app:company_home/cm:Public/cm:Published/cm:Live/*").
            append("\" +").
            append(term).
            append(")");
        } else {
          throw new WebScriptException("Invalid user: " + userName + 
          ". Must have permissions of GROUP_BLOGGER or GROUP_EDITOR");
        }
        logger.debug("query=" + queryBuilder.toString());
        resultSet = searchService.query(
          StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, 
          SearchService.LANGUAGE_LUCENE, queryBuilder.toString());
        for (ChildAssociationRef caref : resultSet.getChildAssocRefs()) {
          NodeRef nodeRef = caref.getChildRef();
          posts.add(getPost(nodeRef));
        }
        tx.commit();
      } catch (Exception e) {
        tx.rollback();
        throw e;
      } finally {
        if (resultSet != null) { resultSet.close(); }
      }
      model.put("posts", posts);
    } catch (Exception e) {
      throw new WebScriptException(e.getMessage(), e);
    }
  }
  
  public void getDashboard(Map<String,Object> model, String userName) 
      throws WebScriptException {
    UserTransaction tx = transactionService.getUserTransaction();
    try {
    tx.begin();
    try {
      String role = getRole(userName);
      if ("blogger".equals(role)) {
        model.put("role", role);
        // contains posts in the blogger's home directory
        List<Post> inboxPosts = getPosts(
          "/app:company_home/app:user_homes/sys:" + 
          WordUtils.capitalize(userName));
        if (! CollectionUtils.isEmpty(inboxPosts)) {
          model.put("inbox", inboxPosts);
        }
      } else if ("editor".equals(role)) {
        model.put("role", role);
        // contains posts under review, and...
        List<Post> reviewPosts = getPosts(
          "/app:company_home/cm:Public/cm:Review");
        if (! CollectionUtils.isEmpty(reviewPosts)) {
          model.put("review", reviewPosts);
        }
        // published posts, and...
        List<Post> publishedPosts = getPosts(
          "/app:company_home/cm:Public/cm:Published/cm:Live");
        if (! CollectionUtils.isEmpty(publishedPosts)) {
          model.put("published", publishedPosts);
        }
        // scheduled posts, and...
        List<Post> scheduledPosts = getPosts(
          "/app:company_home/cm:Public/cm:Published/cm:Scheduled");
        if (! CollectionUtils.isEmpty(scheduledPosts)) {
          model.put("scheduled", scheduledPosts);
        }
        // archived posts
        List<Post> archivedPosts = getPosts(
          "/app:company_home/cm:Public/cm:Published/cm:Archived");
        if (! CollectionUtils.isEmpty(archivedPosts)) {
          model.put("archived", archivedPosts);
        }
      }
      tx.commit();
    } catch (Exception e) {
      tx.rollback();
      throw e;
    }
    } catch (Exception e) {
      throw new WebScriptException(e.getMessage(), e);
    }
  }

  private List<Post> getPosts(String path) throws Exception {
    List<Post> posts = new ArrayList<Post>();
    ResultSet resultSet = null;
    try {
      String query = "+PATH:\"" + path + "/*\" +ASPECT:\"my:publishable\"";
      resultSet = searchService.query(
        StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, 
        SearchService.LANGUAGE_LUCENE, query);
      for (ChildAssociationRef caref : resultSet.getChildAssocRefs()) {
        NodeRef nodeRef = caref.getChildRef();
        posts.add(getPost(nodeRef));
      }
    } catch (Exception e) {
      throw e;
    } finally {
      if (resultSet != null) { resultSet.close(); }
    }
    return posts;
  }

  public String getRole(String userName) {
    Set<String> authorities = authorityService.getAuthoritiesForUser(userName);
    if (authorities.contains("GROUP_GROUP_BLOGGER")) {
      return "blogger";
    } else if (authorities.contains("GROUP_GROUP_EDITOR")) {
      return "editor";
    } else {
      return null;
    }
  }

  private Post getPost(NodeRef nodeRef) {
    Map<QName,Serializable> props = nodeService.getProperties(nodeRef);
    Post post = new Post();
    post.setId((String) props.get(ContentModel.PROP_NODE_UUID));
    post.setTitle((String) props.get(ContentModel.PROP_TITLE));
    post.setFurl((String) props.get(MyContentModel.PROP_FURL));
    post.setDescription((String) props.get(ContentModel.PROP_DESCRIPTION));
    post.setPubState(props.get(MyContentModel.PROP_PUBSTATE));
    post.setCreated((Date) props.get(ContentModel.PROP_CREATED));
    post.setCreator((String) props.get(ContentModel.PROP_CREATOR));
    return post;
  }
}


Spring Configuration

Finally, we configure these two webscripts in Spring. We create a mycompamy-scripts-context.xml file in the config/alfresco/extension directory, which is loaded via Alfresco's extension mechanism. It defines the two Webscript beans and the helper.

 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
<?xml version="1.0" encoding="UTF-8"?>
<!-- Source: config/alfresco/extension/mycompany-scripts-context.xml -->
<!DOCTYPE beans PUBLIC '-//SPRING//DTD BEAN//EN' 
    'http://www.springframework.org/dtd/spring-beans.dtd'>
<beans>
  <bean id="webscript.com.mycompany.search.search.get"
      class="com.mycompany.alfresco.extension.webscripts.GetSearch"
      parent="webscript">
    <property name="helper" ref="webscriptHelper"/>
  </bean>

  <bean id="webscript.com.mycompany.dashboard.dashboard.get"
      class="com.mycompany.alfresco.extension.webscripts.GetDashboard"
      parent="webscript">
    <property name="helper" ref="webscriptHelper"/>
  </bean>
  
  <bean id="webscriptHelper" 
      class="com.mycompany.alfresco.extension.webscripts.WebscriptHelper">
    <property name="authorityService" ref="authorityService"/>
    <property name="transactionService" ref="transactionService"/>
    <property name="searchService" ref="searchService"/>
    <property name="nodeService" ref="nodeService"/>
  </bean>
</beans>


What's Next?

I am nearing the end of my Alfresco customization project. I still have some more Webscripts to write, but they are likely to be repetitive, so I don't plan on writing about them unless there is something to write about. I do have the CMS client webapp to build, but I plan to take a short break in order to learn Spring-Roo, with which I plan to build the client.

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.