You are here

content_lock.module in Content locking (anti-concurrent editing) 6

Allows users to lock documents for modification.

File

content_lock.module
View source
<?php

/**
 * @file
 * Allows users to lock documents for modification.
 */

/**
 * Implementation of hook_perm().
 */
function content_lock_perm() {
  return array(
    'check out documents',
    'keep documents checked out',
    'administer checked out documents',
  );
}

/**
 * Implementation of hook_help().
 */
function content_lock_help($path, $arg) {
  switch ($path) {
    case 'admin/help#content_lock':
      $output = '<p>' . t("Drupal's default content locking strategy is optimistic, that is, two users may start to edit the same content and the one who is hitting the save button first wins the race, while the other is displayed a message stating <em>this content has been modified by another user, changes cannot be saved</em>.  Depending on the number of editors in your organization this might not be an acceptable solution.") . '</p>';
      $output .= '<p>' . t('The Content locking module implements pessimistic locking, which means that content will be exclusively locked whenever a user starts editing it.  The lock will be automatically released when the user submits the form or navigates away from the edit page.') . '</p>';
      $output .= '<p>' . t('Users may also permanently lock content, to prevent others from editing it.  Content locks that have been "forgotten" can be automatically released after a configurable time span.') . '</p>';
      return $output;
    case 'admin/content/node/content_lock':
      return '<p>' . t('Below is a list of all locked documents. Click on <em>check in</em> to release a lock.') . '</p>';
    case 'user/%user/content_lock':
      return '<p>' . t('Below is a list of all documents locked by you. Click on <em>check in</em> to release a lock.') . '</p>';
  }
}

/**
 * Implementation of hook_menu().
 */
function content_lock_menu() {
  $items['admin/content/node/content_lock'] = array(
    'title' => 'Locked documents',
    'page callback' => 'content_lock_overview',
    'access callback' => 'user_access',
    'access arguments' => array(
      'administer checked out documents',
    ),
    'weight' => 5,
    'type' => MENU_LOCAL_TASK,
  );
  $items['admin/content/node/content_lock/release'] = array(
    'page callback' => 'content_lock_release_item',
    'page arguments' => array(
      5,
      NULL,
    ),
    'access arguments' => array(
      'administer checked out documents',
    ),
    'type' => MENU_CALLBACK,
  );
  $items['admin/content/%/content_lock/releaseown'] = array(
    'page callback' => 'content_lock_release_own_item',
    'page arguments' => array(
      2,
    ),
    'access arguments' => array(
      'check out documents',
    ),
    'type' => MENU_CALLBACK,
  );
  $items['user/%user/content_lock'] = array(
    'title' => 'Locked documents',
    'page callback' => 'content_lock_overview',
    'page arguments' => array(
      1,
    ),
    'access callback' => 'user_access',
    'access arguments' => array(
      'check out documents',
    ),
    'weight' => 5,
    'type' => MENU_LOCAL_TASK,
  );
  $items['user/%user/content_lock/release'] = array(
    'page callback' => 'content_lock_release_item',
    'page arguments' => array(
      4,
      1,
    ),
    'access arguments' => array(
      'check out documents',
    ),
    'type' => MENU_CALLBACK,
  );
  $items['node/%/canceledit'] = array(
    'page callback' => 'content_lock_release_own_item',
    'page arguments' => array(
      1,
    ),
    'access callback' => true,
  );
  $items['ajax/content_lock/%/canceledit'] = array(
    'page callback' => 'content_lock_release_own_item',
    'page arguments' => array(
      2,
      false,
    ),
    'access callback' => true,
  );
  $items['admin/settings/content_lock'] = array(
    'type' => MENU_NORMAL_ITEM,
    'title' => 'Content lock',
    'description' => 'Configuration options for the Content lock module',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'content_lock_admin_settings',
    ),
    'access arguments' => array(
      'administer site configuration',
    ),
    'file' => 'content_lock.admin.inc',
  );
  return $items;
}

/**
 * Implementation of hook_nodeapi().
 */
function content_lock_nodeapi(&$node, $op, $teaser, $page) {
  global $user;
  static $messages_shown = false;
  switch ($op) {
    case 'validate':
      if (isset($node->nid) && _content_lock_is_lockable_node($node) && user_access('check out documents')) {

        // Existing node. Check if we still own the lock.
        if ($lock = content_lock_fetch_lock($node->nid)) {
          if ($lock->uid != $user->uid) {

            // Lock is no longer ours.
            form_set_error('changed', t('Your lock has been removed!') . '<br />' . content_lock_lock_owner($lock) . '<br />' . t('You can still save the content if this user aborts the edit operation without saving changes.'));
          }
        }
        else {

          // Node is not locked. Try to re-lock if node is unchanged.
          if (node_last_changed($node->nid) > $node->changed || !content_lock_node($node->nid, $user->uid)) {
            form_set_error('alsochanged', t('Your lock has been removed due to inactivity or by an administrator. Failed to regain the lock since the document has been changed since.'));
          }
        }
      }
      break;
    case 'update':
      if (_content_lock_is_lockable_node($node)) {
        content_lock_release($node->nid, $user->uid);
      }
      break;
    case 'delete':
      if (_content_lock_is_lockable_node($node)) {
        content_lock_release($node->nid, NULL);
      }
      break;
    case 'view':
      global $user;
      if (!_content_lock_is_lockable_node($node)) {
        break;
      }
      if (!$messages_shown) {
        _content_lock_show_warnings();
        $messages_shown = true;
      }
      if ($node->build_mode != NODE_BUILD_PREVIEW) {
        content_lock_warn_pending_locks($user->uid);

        // check if the user has pending locks and warn him
      }
      break;
  }
}

/**
 * Implementation of hook_form_alter().
 */
function content_lock_form_alter(&$form, $form_state, $form_id) {
  global $user;
  $node = $form['#node'];
  $nid = $form['nid']['#value'];

  /** ******************* General preconditions for locking ***************** */

  // Veto-API. Let other modules veto the locking - so force skiping out of any conditions they want
  // We will use | logic, so if any module denies locking - we deny
  // Be sure to noticte that content_lock also implements this api for his own vetos!
  $skip_lock = FALSE;

  // no veto yet
  $result = module_invoke_all('content_lock_skip_locking', $node, $form_id, $form, $form_state);
  foreach ($result as $bool) {
    if (is_bool($bool)) {
      $skip_lock = $skip_lock | $bool;
    }
  }
  if ($skip_lock == FALSE) {

    // if we should lock or already have been locked, load the unload js. Dont use
    // form alter but rather after build, so it works even for previews
    if (variable_get('content_lock_unload_js', true)) {
      $form['#after_build'][] = '_content_lock_add_unload_js';
    }

    // Add some minor things to the form
    _content_lock_add_nodeadministration($form, $form_state, $form_id);

    // Adding cancel button, if configured
    if (variable_get('content_lock_admin_cancelbutton', true)) {
      _content_lock_add_cancelbutton($form, $form_state, $form_id);
    }

    // If we are handling a preview, skip locking
    if ($form_state['rebuild'] == TRUE) {

      // We dont need anything here right now
    }
    else {
      if ($form_state['submitted'] === FALSE) {

        // Finally set the lock if everthing passed.
        if (content_lock_node($nid, $user->uid) == false) {

          // could not lock node, its locked by someone else
          drupal_goto("node/{$nid}");
        }
      }
    }

    // else if($form_state['submitted'] === TRUE)
    // if it is a submission, we would not need to lock once again, as we had before.
    // as nodeapi insert/update are not called on preview, the node should stay locked until saved or canceled.
  }
}

/* 
 *  Implementation of our own skip_locking api to implement our logic to skip locks
 */
function content_lock_content_lock_skip_locking($node, $form_id, $form, $form_state) {
  global $user;
  $nid = $form['nid']['#value'];

  // Locked node types. Dont mix this up with the content_types you can chose on the admin form of content lock
  // this types are forced due to disfunctionality
  $node_type_blacklist = array(
    'user' => TRUE,
  );

  // Form ids listed here will not be locked
  $form_id_blacklist = array(
    'comment_form' => TRUE,
  );
  if ($node != NULL) {
    $form_id_blacklist['node_type_form'] = TRUE;

    // add the node-type administration
  }

  // Let other modules modify our blacklist
  drupal_alter('content_lock_form_id_blacklist', $form_id_blacklist, $node);
  if ($node_type_blacklist[$node->type] === TRUE || $form_id_blacklist[$form_id] === TRUE || $user->uid <= 0 || !user_access('check out documents') || $node == NULL || empty($nid)) {

    // Preconditions failed, skip the lock
    return TRUE;
  }

  /* **************** Restore the node format ****************************** */

  // _content_lock_is_lockable_node() needs to know the original
  // node format.
  // TODO: this stuff pretty sure on the wrong place here. On preview + no validation errors this should break
  // due to form rebuild and missing values in $form_state['values'] .. only $form['#node'] is preserved in this case
  $old_format = $node->format;
  if (!empty($form_state['values']['content_lock_old_format'])) {
    $old_format = $form_state['values']['content_lock_old_format'];
  }
  $form['content_lock_old_format'] = array(
    '#type' => 'hidden',
    '#value' => $old_format,
  );

  // Needs to be manually set before first form submission.
  // We set this in the $node-> namespace because content_lock_nodeapi()
  // doesn't see $form_state['values'].
  $node->content_lock_old_format = $old_format;

  // Check if the current node type and format type is configured to be locked
  if (!_content_lock_is_lockable_node($node)) {

    // It should not be locked, so skip the lock
    return TRUE;
  }

  // we have no veto, so lock the node
  return FALSE;
}
function _content_lock_add_unload_js(&$form, $form_state) {
  $m = drupal_get_path('module', 'content_lock');
  drupal_add_js("{$m}/js/jquery.url.packed.js", 'module');
  drupal_add_js("{$m}/js/onUserExit.js", 'module');
  drupal_add_js("{$m}/js/content_lock_init.js", 'module');
  drupal_add_js(array(
    'content_lock' => array(
      'nid' => $form['#node']->nid,
    ),
  ), 'setting');
  return $form;
}
function _content_lock_verbose() {
  return variable_get('content_lock_admin_verbose', true);
}
function _content_lock_add_nodeadministration(&$form, $form_state, $form_id) {
  $form['buttons']['#prefix'] = "<div class='buttons-wrapper'>";
  $form['buttons']['#suffix'] = "</div>";
}
function _content_lock_add_cancelbutton(&$form, $form_state, $form_id) {
  if (isset($form['#id'])) {
    if ($form['#id'] == 'comment-form') {
      $node = $form['#node'];
      $form['cancel']['#type'] = 'markup';
      $form['cancel']['#weight'] = 2000;
      $form['cancel']['#value'] = l(t('Cancel'), 'node/' . $form['nid']['#value'] . "/canceledit", array(
        'attributes' => array(
          'class' => 'form-submit form-submit-cancel',
        ),
      ));
    }
    else {
      if ($form['#id'] == 'user-profile-form') {
        $form['buttons'] = array(
          '#weight' => 20000,
        );
        $form['buttons']['submit'] = $form['submit'];
        $form['buttons']['submit']['#weight'] = -5;
        if (isset($form['delete'])) {
          $form['buttons']['delbtn'] = $form['delete'];
          $form['buttons']['delbtn']['#weight'] = -4;
        }
        $form['buttons']['cancel']['#type'] = 'markup';
        $form['buttons']['cancel']['#weight'] = 2000;
        $form['buttons']['cancel']['#value'] = l(t('Cancel'), 'user/' . $form['#uid'] . "/canceledit", array(
          'attributes' => array(
            'class' => 'form-submit form-submit-cancel',
          ),
        ));
        unset($form['submit']);
        unset($form['delete']);
      }
      else {
        if ($form['#id'] == 'node-form') {
          $node = $form['#node'];
          $form['buttons']['cancel']['#type'] = 'markup';
          $form['buttons']['cancel']['#weight'] = 2000;
          $form['buttons']['cancel']['#value'] = l(t('Cancel'), 'node/' . $node->nid . "/canceledit", array(
            'attributes' => array(
              'class' => 'form-submit form-submit-cancel',
            ),
          ));
          if (isset($form['buttons']['delete'])) {
            $form['buttons']['delete']['#weight'] = 2001;
          }
        }
        else {
          if ($form['#id'] == 'node-form' && arg(0) == 'node') {
            $node = $form['#node'];
            $form['buttons']['cancel']['#type'] = 'markup';
            $form['buttons']['cancel']['#weight'] = 2000;
            $form['buttons']['cancel']['#value'] = l(t('Cancel'), '', array(
              'attributes' => array(
                'class' => 'form-submit form-submit-cancel',
              ),
            ));
          }
        }
      }
    }
  }
}

/**
 * Fetch the lock for a node.
 *
 * @param $nid
 *   A node id.
 * @return
 *   The lock for the node. FALSE, if the document is not locked.
 */
function content_lock_fetch_lock($nid) {
  return db_fetch_object(db_query("SELECT c.*, u.name FROM {content_lock} c LEFT JOIN {users} u ON u.uid = c.uid WHERE c.nid = %d", $nid));
}

/**
 * Tell who has locked node.
 *
 * @param $lock
 *   The lock for a node.
 * @return
 *   String with the message.
 */
function content_lock_lock_owner($lock) {
  $username = theme('username', $lock);
  $date = format_date($lock->timestamp, 'medium');
  return t('This document is locked for editing by !name since @date.', array(
    '!name' => $username,
    '@date' => $date,
  ));
}
function content_lock_user($op, $edit, $account) {
  switch ($op) {
    case 'logout':

      // removing all locks, as the user logs out
      _content_lock_release_all_user_locks($account->uid);
      break;
  }
}

/**
 * Try to lock a document for editing.
 *
 * @param $nid
 *   A node id.
 * @param $uid
 *   The user id to lock the node for.
 * @return
 *   FALSE, if a document has already been locked by someone else.
 */
function content_lock_node($nid, $uid) {
  $lock = content_lock_fetch_lock($nid);
  if ($lock != FALSE && $lock->uid != $uid) {
    if (user_access('administer checked out documents')) {
      $message = content_lock_lock_owner($lock);
      $url = "admin/content/node/content_lock/release/{$nid}";
    }
    if (isset($url)) {
      $message .= '<br />' . t('Click <a href="!release-url">here</a> to check back in now.', array(
        '!release-url' => url($url, array(
          'query' => 'destination=' . $_GET['q'],
        )),
      ));
    }
    if (!empty($message)) {
      drupal_set_message($message, 'error');
    }
    return FALSE;
  }
  else {

    // no lock yet, create one
    if ($lock == false) {

      // Lock node.
      $data = array(
        'nid' => $nid,
        'uid' => $uid,
        'timestamp' => time(),
      );
      drupal_write_record('content_lock', $data);
    }
    if (_content_lock_verbose()) {
      drupal_set_message(t('This document is now locked against simultaneous editing. It will unlock when you navigate elsewhere.'));
    }
    module_invoke_all('content_lock_locked', $nid, $uid);
  }
  return TRUE;
}

/**
 * Release a locked node.
 *
 * @param $nid
 *   The node id to release the edit lock for.
 * @param $uid
 *   If set, verify that a lock belongs to this user prior to release.
 */
function content_lock_release($nid, $uid = NULL) {
  $add_sql = '';
  $args = array(
    $nid,
  );
  if (isset($uid)) {
    $add_sql = " AND uid = %d";
    $args[] = $uid;
  }
  db_query("DELETE FROM {content_lock} WHERE nid = %d" . $add_sql, $args);
  module_invoke_all('content_lock_released', $nid);
}
function _content_lock_release_all_user_locks($uid) {
  db_query("DELETE FROM {content_lock} WHERE uid = %d", $uid);
}

/**
 * Build an overview of locked documents.
 *
 * @param $account
 *   A user object.
 */
function content_lock_overview($account = NULL) {
  $header = array(
    array(
      'data' => t('Title'),
      'field' => 'n.title',
      'sort' => 'asc',
    ),
  );
  if (!$account) {
    $header[] = array(
      'data' => t('Username'),
      'field' => 'u.name',
    );
    $uid = NULL;
  }
  else {
    $uid = $account->uid;
  }
  $header[] = array(
    'data' => t('Locked since'),
    'field' => 'c.timestamp',
  );
  $header[] = t('Operations');
  $rows = array();
  $add_sql = $uid ? " WHERE c.uid = %d" : '';
  $result = pager_query('SELECT c.*, n.title, u.name FROM {content_lock} c INNER JOIN {node} n ON n.nid = c.nid INNER JOIN {users} u ON u.uid = c.uid' . $add_sql . tablesort_sql($header), 50, 0, NULL, $uid);
  $url = $uid ? "user/{$uid}/content_lock/release" : 'admin/content/node/content_lock/release';
  while ($data = db_fetch_object($result)) {
    $row = array();
    $row[] = l($data->title, "node/{$data->nid}");
    if (!$uid) {
      $row[] = theme('username', user_load(array(
        'uid' => $data->uid,
      )));
    }
    $row[] = format_date($data->timestamp, 'small');
    $row[] = l(t('release lock'), "{$url}/{$data->nid}");
    $rows[] = $row;
  }
  $output = theme('table', $header, $rows, array(
    'id' => 'content_lock',
  ));
  if (!$rows) {
    $output .= t('No locked documents.');
  }
  else {
    if ($pager = theme('pager', array(), 50, 0)) {
      $output .= $pager;
    }
  }
  return $output;
}

/**
 * Menu callback; release a locked node for all users or a specific user.
 *
 * @param $nid
 *   A node id.
 * @param $account
 *   A user object.
 * @return
 *   This function will execute a redirect and doesn't return.
 */
function content_lock_release_item($nid, $account = NULL) {
  content_lock_release($nid, $account ? $account->uid : NULL);
  if (_content_lock_verbose()) {
    drupal_set_message(t('The editing lock has been released.'));
  }
  drupal_goto($account ? "user/{$account->uid}/content_lock" : 'admin/content/node/content_lock');
}

/**
 * For every lock a user current have on any nodes, print a warning messagt
 * with an link to release this node.
 *
 */
function content_lock_warn_pending_locks($uid) {

  // cache
  static $warned_nodes = array();
  static $content_lock_messages_printed = false;
  if ($content_lock_messages_printed) {
    return;
  }
  if (array_key_exists($uid, $warned_nodes)) {

    // do nothing
  }
  else {

    // load form db
    $warned_nodes[$uid] = array();
    $result = db_query("SELECT cl.nid,n.title FROM {content_lock} as cl LEFT JOIN {node} as n on cl.nid=n.nid WHERE cl.uid = %d", $uid);
    while ($lock = db_fetch_object($result)) {
      $warned_nodes[$uid][] = $lock;
    }
  }
  foreach ($warned_nodes[$uid] as $lock) {
    $editlink = l(t('editing'), "node/{$lock->nid}/edit");
    $unlocklinkhere = l(t('here'), "admin/content/{$lock->nid}/content_lock/releaseown");
    _content_lock_save_lock_warning(t("You are currently preventing the node '!nodetitle' from being edited. You may want to let others edit this node by clicking !unlocklinkhere, or finish !edit the node.", array(
      '!nodetitle' => $lock->title,
      '!edit' => $editlink,
      '!unlocklinkhere' => $unlocklinkhere,
    )), $lock->nid);
  }
  $content_lock_messages_printed = true;
}
function _content_lock_save_lock_warning($message, $nid) {
  if (empty($_SESSION['content_lock'])) {
    $_SESSION['content_lock'] = '';
  }
  $data = unserialize($_SESSION['content_lock']);
  if (!is_array($data)) {
    $data = array();
  }
  if (array_key_exists($nid, $data)) {
    return;
  }
  $data[$nid] = $message;
  $_SESSION['content_lock'] = serialize($data);
}
function _content_lock_show_warnings() {
  global $user;
  if (empty($_SESSION['content_lock'])) {
    return;
  }
  $data = unserialize($_SESSION['content_lock']);
  if (!is_array($data) || count($data) == 0) {
    return;
  }
  foreach ($data as $nid => $messsage) {
    if (_content_lock_still_locked($user->uid, $nid) > 0) {
      drupal_set_message($messsage, 'warning');
    }
  }
  $_SESSION['content_lock'] = '';
}
function _content_lock_still_locked($uid, $nid) {
  $result = db_result(db_query("SELECT count(cl.nid) FROM {content_lock} as cl  WHERE cl.uid = %d AND cl.nid=%d", $uid, $nid));
  return $result;
}

/**
 * Release the lock of a node. We are using the current users uid, so the user only can delete
 * his own locks. We never fail, as if the lock does not exist, the node is unlocked anyway
 */
function content_lock_release_own_item($nid, $response = true) {
  global $user;
  if ($nid != NULL) {
    content_lock_release($nid, $user->uid);

    // drupal_get_messages();
    if ($response) {
      drupal_goto("node/{$nid}");
    }
    else {
      exit;
    }
  }
  else {

    // thats what we do, if a user was creating a node and canceled
    if ($response) {
      drupal_goto();
    }
    else {
      exit;
    }
  }
}

/**
 * Check whether a node is configured to be protected by content_lock.
 */
function _content_lock_is_lockable_node($node) {
  static $lockable;

  // To catch the case where the user is changing the input format,
  // we store the original input format.
  $format = $node->format;
  if (!empty($node->content_lock_old_format)) {
    $format = $node->content_lock_old_format;
  }

  // Check for a cache hit
  if (isset($lockable[$format][$node->nid])) {
    return $lockable[$format][$node->nid];
  }
  $types = array_filter(variable_get('content_lock_allowed_node_types', array()));

  // Let other modules modify our blacklist
  drupal_alter('content_lock_node_type_blacklist', $types, $node);
  $formats = array_filter(variable_get('content_lock_allowed_formats', array()));
  $lockable[$format][$node->nid] = FALSE;

  // Determine if the node is of a lockable content type or text format.
  if ((empty($types) || in_array($node->type, $types)) && (empty($formats) || in_array($format, $formats))) {
    $lockable[$format][$node->nid] = TRUE;
  }
  return $lockable[$format][$node->nid];
}
function content_lock_views_api() {
  return array(
    'api' => 2.0,
    'path' => drupal_get_path('module', 'content_lock') . '/views',
  );
}

Functions

Namesort descending Description
content_lock_content_lock_skip_locking
content_lock_fetch_lock Fetch the lock for a node.
content_lock_form_alter Implementation of hook_form_alter().
content_lock_help Implementation of hook_help().
content_lock_lock_owner Tell who has locked node.
content_lock_menu Implementation of hook_menu().
content_lock_node Try to lock a document for editing.
content_lock_nodeapi Implementation of hook_nodeapi().
content_lock_overview Build an overview of locked documents.
content_lock_perm Implementation of hook_perm().
content_lock_release Release a locked node.
content_lock_release_item Menu callback; release a locked node for all users or a specific user.
content_lock_release_own_item Release the lock of a node. We are using the current users uid, so the user only can delete his own locks. We never fail, as if the lock does not exist, the node is unlocked anyway
content_lock_user
content_lock_views_api
content_lock_warn_pending_locks For every lock a user current have on any nodes, print a warning messagt with an link to release this node.
_content_lock_add_cancelbutton
_content_lock_add_nodeadministration
_content_lock_add_unload_js
_content_lock_is_lockable_node Check whether a node is configured to be protected by content_lock.
_content_lock_release_all_user_locks
_content_lock_save_lock_warning
_content_lock_show_warnings
_content_lock_still_locked
_content_lock_verbose