Extending Your Workflow with State Machine

As Roger pointed out in his Energy.gov case study, State Machine served as the basis for creating an extendable and exportable workflow. In this post, I'll talk about the inner workings of State Machine, it's included counterpart module, State Flow, and how to take advantage of the API to build your own custom workflow.

What is a State Machine?

Fredric Mitchell
#Drupal | Posted

As Roger pointed out in his Energy.gov case study, State Machine served as the basis for creating an extendable and exportable workflow. In this post, I'll talk about the inner workings of State Machine, it's included counterpart module, State Flow, and how to take advantage of the API to build your own custom workflow.

What is a State Machine?

A finite state machine consists of a determined number of events, each associated with a transition. The State Machine module is actually just an API. If you enable it by itself, nothing will happen on your site. It serves as a foundation to build a customized workflow in code through various methods provided in the StateMachine class.

 

 /**
* Create a new state. This method does not actually create a state instance,
* it only stores the options array until an instance is requested from
* get_state().
*
* @param $key
*   The string identifier for this state.  Must be unique within the scope
*   of the State Machine.
* @param $options
*   An array of options that will be passed into the State constructor.
*   - title: The human-readable title of the state.
*   - on_enter: An array of callbacks to be fired when entering this state.
*   - on_exit: An array of callbacks to be fired when exiting this state.
*/

protected function create_state($key, $options = array()) {
$this->states[$key] = $options;
}/**
* Return a state instance by key, lazy-loading the instance if necessary.
*
* @param $key
*   The string identifier of the state to be returned.
* @return
*   An instance of StateMachine_State, or FALSE if the key specified does
*   not represent a valid state.
*/

protected function get_state($key) {
if (!array_key_exists($key, $this->states)) {
return FALSE;
}

if (is_array($this->states[$key])) {
$options = $this->states[$key];
$this->states[$key] = new StateMachine_State($this, $options);
}

return $this->states[$key];
}

/**
* Set the current state to the state identified by the specified key.
*/

protected function set_current_state($key) {
if (array_key_exists($key, $this->states)) {
$this->current = $key;
return TRUE;
}
return FALSE;
}

The State Machine does come packaged with an out-of-the-box implementation of its API through State Flow. State Flow not only provides a workflow for your content types, but it serves as a model for developers to leverage the full power of the State Machine class.

State Flow provides an immediate solution, once enabled, for a basic workflow across all content types. Because it is an implementation of the State Machine API, there aren't any administration pages with options to configure.

Let's first dive into the State Flow class.

The State Flow Plugin

The first step is to define out states, how the transition is defined between them (events), and add any permissions for those transitional actions if necessary.

 

class StateFlow extends StateMachine {
/**
* Called from StateMachine::__construct to initialize the states and events.
* Define two states.
* First revision:
*  - Expose go to draft button
*  - Expose go to publish button
*  - Upon publish, create new revision (handled outside of state machine)
* Second revision:
*  - Menu alter edit link to load most recent revision (whether published or revision from states)
*  - On hook_nodeapi (op: load), force new revision checkbox on node edit form
*    - Expose go to draft button
*  - Create new revision, prevent node table from updating new revision as published revision
*  - Expose go to publish button
*  - Upon publish, set revision id in node table
*  - Repeat as needed
*/

public function init() {
// Initialize states
$this->create_state('draft');
$this->create_state('published', array(
'on_enter' => array($this, 'on_enter_published'),
'on_exit' => array($this, 'on_exit_published'),
));
$this->create_state('unpublished');// Initialize events
$this->create_event('publish', array(
'origin' => 'draft',
'target' => 'published',
));
$this->create_event('unpublish', array(
'origin' => 'published',
'target' => 'unpublished',
'permission' => 'publish and unpublish content',
));
$this->create_event('to draft', array(
'origin' => 'unpublished',
'target' => 'draft',
));
}
}

In the code above, we've defined three states: draft, published, and unpublished. We've also defined three events that enable transition between the states: publish, unpublish, and to draft.

You'll also notice that the 'published' state has 'on_enter' and 'on_exit' properties set. This allows us to map methods to these properties to ensure the action that defines that state is always set, almost like a definition.

In this case, an entity that is entering the 'published' state is defined as always having a status equal to 1, the latest revision is set, and that latest revision set as the principle.

The same is true for the definition of an entity exiting the 'published' state, where that circumstance demands that it should always have it's status set to 0.

State Flow works with the Drupal revisioning system of nodes, so most of the logic to implement these methods are in the module. The above states and events are defined in a plugin which extends the State Machine class. The plugin is defined through CTools, thus State Flow has a dependency on CTools.

The State Flow Module

Since the plugin defines the states and events, the module uses various hooks to implement those methods on nodes. Before it does that, however, it defines a pair custom tables to keep the principle version id of a node and the node's state history.

 

/**
* Implements hook_schema().
*/

function state_flow_schema() {
$schema['node_revision_states'] = array(
'description' => 'Saves the current live vid of a node',
'fields' => array(
'vid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'nid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'state' => array('type' => 'varchar', 'length' => '255', 'not null' => FALSE),
'status' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'timestamp' => array('type' => 'int', 'unsigned' => TRUE, 'not null', TRUE, 'default' => 0),
),
'primary key' => array('vid'),
'indexes' => array(
'nid' => array('nid'),
),
);
$schema['node_revision_states_history'] = array(
'description' => 'Saves the state history of a node revision.',
'fields' => array(
'vid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'nid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'state' => array('type' => 'varchar', 'length' => '255', 'not null' => FALSE),
'timestamp' => array('type' => 'int', 'unsigned' => TRUE, 'not null', TRUE, 'default' => 0),
'uid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
'log' => array('type' => 'text', 'not null' => FALSE, 'size' => 'big'),
),
'indexes' => array(
'vid' => array('vid'),
'nid' => array('nid'),
),
);return $schema;
}

State Flow has responsibilities at each phase of node processing, with the most important being able to determine the state machine for a given node. The state_flow_load_state_machine() function accomplishes this task.

 

/**
* Load the state_flow state_machine for the given node.
*/

function state_flow_load_state_machine($node, $reset = FALSE) {
$objects = &drupal_static(__FUNCTION__);if (!isset($objects[$node->vid]) && !$reset) {
ctools_include('plugins');

$machine_type = variable_get('state_flow_' . $node->type, 'state_flow');
$plugin = ctools_get_plugins('state_flow', 'plugins', $machine_type);

if (!empty($plugin)) {
$class = ctools_plugin_get_class($plugin, 'handler');
$state_flow_object = new $class($node);
$objects[$node->vid] = $state_flow_object;
}
}
return $objects[$node->vid];
}

In the subsequent node hooks, you can now act on the node as needed at the desired time.

 

/**
* Implements hook_node_presave().
*/

function state_flow_node_presave($node) {
// If the node is not new and is not marked to be ignored by
// state_flow_promote_node_revision(), then check its current state.
if (!empty($node->nid) && empty($node->state_flow_ignore_state)) {
$state_flow = state_flow_load_state_machine($node);//Check to see if we should go through workflow
if (empty($node->stateflow_skip_workflow)) {
$state = $state_flow->get_current_state();
if ($state == 'published') {
// If the node being updated is in the published state, then ensure that
// changes are saved to a new revision.
$node->revision = TRUE;
}
else if ($state != 'draft') {
// If the node being updated is not in the draft state, then mark this
// node to be reverted to draft state.
$node->state_flow_revert_draft = TRUE;
}
}
else {
if ($node->status) {
$state_flow->fire_event('publish');
}
}
}
}

/**
* Implements hook_node_insert().
*/

function state_flow_node_insert($node) {
global $user;
$state_flow = state_flow_load_state_machine($node);
$state_flow->persist();
$state_flow->write_history($user->uid);
}

/**
* Implements hook_node_update().
*/

function state_flow_node_update($node) {
global $user;
$state_flow = state_flow_load_state_machine($node);

//Check to see if we should go through workflow
if (empty($node->stateflow_skip_workflow)) {
if (!empty($node->state_flow_revert_draft) && $state_flow->get_current_state() !== 'draft') {
$state_flow->fire_event('to draft');
}
else {
$state_flow->persist();
if (!empty($node->revision)) {
$state_flow->write_history($user->uid);
}
}
state_flow_prevent_live_revision($node);
}
}

You'll note that State Flow also allows a condition for the workflow to be skipped if you choose to set that in your extension (more on that later).

You may be wondering why there aren't a ton of permissions to set or why State Flow doesn't alter the node form and hide the publishing form element? State Flow is designed to be simple and to be extended. While many of these desires are all valid assumptions that could have been baked right in, because State Flow is a base plugin of State Machine, it allows for the community to develop their own plugins and tailor the workflow more accutely to their needs.

Let's see just how to do that, using an example from the recently launched Energy.gov site.

Writing Plugins and Extending State Flow

The first is to define your plugin inside your .module file via CTools.

 

/**
* Implements hook_state_flow_plugins().
*/

function energy_workflow_state_flow_plugins() {
$info = array();
$path = drupal_get_path('module', 'energy_workflow') . '/plugins';
$info['energy_workflow'] = array(
'handler' => array(
'parent' => 'state_flow',
'class' => 'EnergyWorkflow',
'file' => 'energy_workflow.inc',
'path' => $path,
),
);
return $info;
}

Within the plugin, you simply need to define your states and events. If you just want to add more states before actually getting to publishing, you can do so as the methods mapped to the 'publish' transition have already been defined within State Flow. Because we are extending State Flow, this is very easy.

 

/**
* @file
* Energy.gov implementation of State Flow, an extension of the State Machine class
*/
class EnergyWorkflow extends StateFlow {

public function init() {
// Initialize states
$this->create_state('draft');
$this->create_state('needs review');
$this->create_state('approved');
$this->create_state('unpublished');
$this->create_state('published', array(
'on_enter' => array($this, 'on_enter_published'),
'on_exit' => array($this, 'on_exit_published'),
));

// Initialize events
$this->create_event('for review', array(
'origin' => 'draft',
'target' => 'needs review',
));
$this->create_event('immediate publish', array(
'origin' => 'draft',
'target' => 'published',
'guard' => 'energy_workflow_guard_publisher',
));
$this->create_event('approve', array(
'origin' => 'needs review',
'target' => 'approved',
'guard' => 'energy_workflow_guard_editor',
));
$this->create_event('reject', array(
'origin' => 'needs review',
'target' => 'draft',
'guard' => 'energy_workflow_guard_editor',
));
$this->create_event('publish', array(
'origin' => 'approved',
'target' => 'published',
'guard' => 'energy_workflow_guard_publisher',
));
$this->create_event('unpublish', array(
'origin' => 'published',
'target' => 'unpublished',
'guard' => 'energy_workflow_guard_publisher',
));
$this->create_event('to draft', array(
'origin' => array('needs review', 'approved', 'unpublished'),
'target' => 'draft',
));
}

public function on_event_fail($event) {
$key = array_search($event, $this->events);
drupal_set_message(t('Could not transition node using %event event.', array('%event' => $key)), 'error');
}
}

You'll notice that the Energy.gov project also took advantage of an additional property within events called 'guard'. This is useful if you want to define outcomes if conditions are not matched to allow for the event to fire. This is especially useful if you want to create custom permissions.

 

/**
* Implements hook_permission().
*/

function energy_workflow_permission() {
return array(
'approve and reject content' => array(
'title' => t('Approve and reject content'),
'description' => t('Approve or reject content, transitioning it to the approved or draft state.'),
),
'publish and unpublish content' => array(
'title' => t('Publish and unpublish content'),
'description' => t('Publish or unpublish content, transitioning it to the published or unpublished state.'),
),
'view any unpublished content' => array(
'title' => t('View any unpublished content'),
),
);
}

Since we set guard conditions in our plugin, we can define those callbacks as well.

 

/**
* Guard callback for the EnergyWorkflow approve and reject events.
*/

function energy_workflow_guard_editor($event) {
return energy_workflow_guard_permission($event, 'approve and reject content');
}/**
* Guard callback for the EnergyWorkflow publish and unpublish events.
*/

function energy_workflow_guard_publisher($event) {
return energy_workflow_guard_permission($event, 'publish and unpublish content');
}

/**
* Helper function for evaluating an access decision with either the global or
* group-specific permission.
*/

function energy_workflow_guard_permission($event, $permission) {
// If the user has the global permission, then return TRUE
if (user_access($permission)) {
return TRUE;
}

// Otherwise, check for the permission in the node's group
$machine = $event->get_machine();
$node = $machine->get_object();
$groups = field_get_items('node', $node, 'group_audience');
$gid = !empty($groups[0]['gid']) ? $groups[0]['gid'] : FALSE;
if ($gid && og_user_access($gid, $permission)) {
return TRUE;
}

// Otherwise, return FALSE
return FALSE;
}

The extra check in energy_workflow_guard_permission is because we used organic groups on the Energy.gov platform.

Workbench vs State Machine

Once of the most common questions when we started discussing State Machine was how it was different from Workbench, specifically Workbench Moderation.

The simple answer is that State Machine is an API with a base module to allow developers to build their own workflow whereas Workbench provides workflow administration exposed through the Drupal interface that does not provide a robust API.

It really comes down to your preference, but we believe State Machine serves a need for those who want to customize, extend, and export their workflow within their projects.

Want to learn more about State Machine?

Download the State Machine module!Try out the demo!

Vote for this session at Drupalcon Denver 2012!

Fredric Mitchell