array( 'name' => t('Issue'), 'module' => 'issue_tracker', 'description' => t('An issue or bug report.'), 'body_label' => t('Description'), ) ); } /** * Implementation of hook_perm(). */ function issue_tracker_perm() { return array('create issue', 'edit issue', 'delete issue'); } /** * Implementation of hook_access(). */ function issue_tracker_access($op, $node, $account) { global $user; if ($op == 'create') { return user_access('create issue') && $user->uid; } if ($op == 'update') { return user_access('edit issue'); } if ($op == 'delete') { return user_access('delete issue'); } } /** * Implementation of hook_help(). */ function issue_tracker_help($path, $arg) { switch ($path) { case 'admin/help#issue_tracker': $output = '

'. t('The issue tracker module provides bug / issue tracking') .'

'; return $output; } } /** * Implementation of hook_init(). */ function issue_tracker_init() { // AJAX callback to get an issue's status drupal_add_js('Drupal.behaviors.issue_tracker = function(context) { $(\'a[class=extiw issue]\').each(function() { // extract issue number from the URL var issueURL = $(this)[0].href; var issueURLSplitted = issueURL.split(\'/\') var issueNumber = issueURLSplitted[issueURLSplitted.length - 1]; var target= $(this); $.get(Drupal.settings.basePath + "ajax/issue_get_status/" + issueNumber, null, function(response) { var result = Drupal.parseJson(response); var decorationString = ""; if (result.issueClosedStatus == true) { decorationString = \'line-through\'; } $(target).css(\'text-decoration\', decorationString); }); }); }', 'inline'); } /** * Implementation of hook_form(). * This form is displayed when creating new issues or editing an issue (not when replying to an issue) */ function issue_tracker_form(&$node) { global $nid; $iid = $_GET['iid']; $type = node_get_types('type', $node); $form['title'] = array( '#type' => 'textfield', '#title' => check_plain($type->title_label), '#required' => TRUE, '#default_value' => $node->title, '#weight' => -5 ); $form['body_filter']['body'] = array( '#type' => 'textarea', '#title' => check_plain($type->body_label), '#default_value' => $node->body, '#rows' => 20, '#required' => False ); $form['body_filter']['filter'] = filter_form($node->format); $form['#submit'] = array('issue_tracker_form_submit'); return $form; } /** * Implementation of hook_menu(). */ function issue_tracker_menu() { $items = array(); $items['issues'] = array( 'title' => 'Issues', 'page callback' => 'issues_page', 'access callback' => 'user_access', 'access arguments' => array('access content'), ); // Issue Filter redirect // filtername for redirection is fetched by loader-function and passed to drupal_goto $items['issues/filter/%filtername'] = array( 'title' => 'Issues Filter Bookmark', 'page callback' => 'issues_page', 'page arguments' => array(2), 'access callback' => 'user_access', 'access arguments' => array('access content'), 'load arguments' => array('%map', '%index'), 'type' => MENU_CALLBACK ); // Issue details redirect // id for redirection is fetched by loader-function and passed to drupal_goto $items['issues/%issue'] = array( 'title' => 'Issues', 'page callback' => 'drupal_goto', 'page arguments' => array(1), 'access callback' => 'user_access', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK ); //The ajax way for getting issues $items['ajax/get_issues'] = array( 'page callback' => 'issue_get_data', 'access callback' => 'user_access', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK ); //Getting filters via ajax $items['ajax/issue_filters'] = array( 'page callback' => 'issue_filter_action', 'access callback' => 'user_access', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK ); $items['ajax/issue_get_status/%'] = array( 'page callback' => 'issue_tracker_get_closed_status', 'page arguments' => array(2), 'access callback' => 'user_access', 'access arguments' => array('access content'), 'type' => MENU_CALLBACK ); // Add 'delete' tab $items['node/%node/delete_issue'] = array( 'title' => t('Delete'), 'page callback' => 'drupal_get_form', 'page arguments' => array('node_delete_confirm', 1), 'access callback' => '_issue_tracker_delete_tab_access', 'access arguments' => array(1), 'weight' => 2, 'type' => MENU_LOCAL_TASK, 'file' => 'node.pages.inc', 'file path' => drupal_get_path('module', 'node') ); return $items; } /** * Implementation of hook_theme(). */ function issue_tracker_theme() { return array( 'issue_tracker_pager' => array( 'arguments' => array('limit' => 25, 'element' => 0) ), 'issue_tracker_pager_first' => array( 'arguments' => array('text' => NULL, 'limit' => NULL, 'element' => 0) ), 'issue_tracker_pager_previous' => array( 'arguments' => array('text' => NULL, 'limit' => NULL, 'element' => 0, 'interval' => 1, 'parameters' => array()) ), 'issue_tracker_pager_next' => array( 'arguments' => array('text' => NULL, 'limit' => NULL, 'element' => 0, 'interval' => 1) ), 'issue_tracker_pager_last' => array( 'arguments' => array('text' => NULL, 'limit' => NULL, 'element' => 0) ), 'issue_tracker_pager_list' => array( 'arguments' => array('limit' => NULL, 'element' => 0, 'quantity' => 5, 'text' => '') ), 'issue_tracker_pager_link' => array( 'arguments' => array('text' => NULL, 'target_page' => NULL, 'element' => NULL, 'attributes' => array()) ), 'issue_tracker_filter' => array( 'arguments' => array('filters' => NULL, 'id' => NULL), ), 'issue_tracker_hidden_filter' => array( 'arguments' => array('filters' => NULL, 'id' => NULL), ), 'issue_tracker_free_filter' => array( 'arguments' => array('filters' => NULL), ), ); } /** * Menu callback for path 'issues/$id'. * Returns the path and node-ID for issue `$id' to pass to drupal_goto(). * * @param $issue_id of the issue * @return node-ID of the requested issue. */ function issue_load($issue_id) { if (!is_numeric($issue_id)) { return FALSE; } $issue_query = db_query("SELECT nid FROM {issues} WHERE issue_id = %d", $issue_id); $issue = db_fetch_object($issue_query); return 'node/'. $issue->nid; } /** * Menu callback for path 'issues/filter/$filtername'. * Returns the path and filtername for filter `$fid'. * * @param $filter_id of the filter * @return path of the requested filter. */ function filtername_load($filtername, $map_array, $index) { global $user; if (!is_string($filtername)) { return FALSE; } foreach ($map_array as $i => $value) { if ($i == 2) { $fullfilter = $map_array[$i]; } if ($i > 2) { $fullfilter .= '/' . $map_array[$i]; } } $filter_query = db_query("SELECT fid, name, url FROM {issue_filter} f WHERE f.uid=%d AND name = LOWER('%s')", $user->uid, $fullfilter); $filter = db_fetch_object($filter_query); return $filter; } /** * Implementation of hook_link(). */ function issue_tracker_link($type, $node = 0, $main = 0) { $links = array(); switch ($type) { case 'node': if ($node->type == 'issue') { if (user_access('create issue')) { $links['create issue'] = array( 'title' => t('Post Reply'), 'href' => "comment/reply/$node->nid", 'fragment' => 'comment-form', ); } } break; } return $links; } /** * Implementation of hook_link_alter(). */ function issue_tracker_link_alter(&$links, $node) { if ($node->type == 'issue' && isset($links['comment_add'])) { $links['comment_add']['title'] = "Post Reply"; $links['comment_add']['attributes']['title'] = "Reply to this issue."; } } /** * Implementation of hook_token_list(). * Defines a custom token type for issues, which can be used by the path (and * pathauto) module to generate url-aliases for issues referencing their IDs. * * @param type * A flag indicating the class of substitution tokens to return * information on. If this is set to 'all', a complete list is being * built and your module should return its full list, regardless of * type. Global tokens should always be returned, regardless of the * $type passed in. * @return * A keyed array listing the substitution tokens. Elements should be * in the form of: $list[$type][$token] = $description */ function issue_tracker_token_list($type = 'all') { if ($type == 'node') { $tokens['node']['issue-id'] = t('The Origo issue-ID of the issue-node.'); }; return $tokens; }; /** * Implementation of hook_token_values(). * Returns the Origo issue-ID for replacing [issue-id] tokens. * * @param type * A flag indicating the class of substitution tokens to use. If an * object is passed in the second param, 'type' should contain the * object's type. For example, 'node', 'comment', or 'user'. If your * implemention of the hook inserts globally applicable tokens that * do not depend on a particular object, it should only return values * when $type is 'global'. * @param object * Optionally, the object to use for building substitution values. * A node, comment, user, etc. * @return * A keyed array containing the substitution tokens and the substition * values for the passed-in type and object. */ function issue_tracker_token_values($type, $object = NULL, $options = array()) { $values = array(); switch ($type) { case 'node' : $node = $object; // TODO: is there a better way to check if $node is an issue-node? // bherlig, 2010-01-19 if (isset($node->issue_id)) { $values['issue-id'] = $node->issue_id; } break; }; return $values; }; /** * Implementation of hook_view(). * Displays the issue content together with the issue replies which have customized rendering. * Replying to an issue invokes this _view hook with $page = FALSE */ function issue_tracker_view($node, $teaser = FALSE, $page = FALSE) { global $user; //prevent comments from being displayed if ($node->type == 'issue') { // add issue-header $node->issue_header = _issue_tracker_build_issue_header(); if ($page) { // Breadcrumb navigation $breadcrumb[] = l(t('Home'), NULL); $breadcrumb[] = l(t('issues'), 'issues'); drupal_set_breadcrumb($breadcrumb); } $issue_query = db_query("SELECT issue_id FROM {issues} WHERE nid = %d", $node->nid); $issue = db_fetch_object($issue_query); if ($page) { drupal_set_title('#'. $issue->issue_id .' - '. htmlentities(utf8_decode($node->title))); } else { $node->title = '#'. $issue->issue_id .' - '. utf8_decode($node->title); } $node->comment = 0; $node->content['issue_comments'] = array( '#value' => issue_comment_view($node), '#weight' => 2, ); $planning_text = ''; if (isset($user->uid) && $user->uid > 0) { $issue_data = origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.retrieve_planning_data', (int)variable_get('origo_project_id', 0), (int)$issue->issue_id); if ($issue_data['deadline'] > 0) { // suppress timestamp 0 $planning_text .= t('Deadline: ') . gmdate('Y-m-d', (int)$issue_data['deadline']); } if ($issue_data['work_amount'] > 0) { $planning_text .= '
'. t('Estimated amount of work: ') . $issue_data['work_amount'] . format_plural($issue_data['work_amount'], ' day', ' days'); } } $node->content['planning'] = array( '#value' => $planning_text, '#weight' => -2 ); if (isset($user->uid) && $user->uid > 0) { $terms = taxonomy_node_get_terms($node); $tags = array(); foreach ($terms as $term) { $tags[] = $term->name; } $is_subscribed = FALSE; foreach ($tags as $tag) { if ($tag == 'subscribed::'. $user->name) { $is_subscribed = TRUE; } } $node->content['subscription'] = array( '#value' => drupal_get_form('issue_subscription_form', $node->nid, $is_subscribed, implode(', ', $tags)), '#weight' => -1 ); } } return node_prepare($node, $teaser); } /** * Implementation of hook_validate(). * We misuse the validation hook to insert an issue into the backend. This way, * we can abort creating a Drupal node in case the back-end failed creating the * node there (quite possibly because it crashed and is not available). * This should fix #675 for good (or at least until there's a better way). */ function issue_tracker_form_validate($form, &$form_state) { // If the node already exists, we're not interested in it. Return immediately. if (isset($form['#node']) && isset($form['#node']->nid)) { return True; } // Trim title $form_state['values']['title'] = trim($form_state['values']['title']); // Only execute if the form was really submitted - but not if it's just a preview. // TODO: is there a better way to decide if it's a preview or a submission? // bherlig, 2010-06-16 if ($form_state['clicked_button']['#id'] == 'edit-submit') { // the user submitted the form -> try to insert the issue into the back-end. $vid = _issue_tracker_get_issue_vocabulary_id(); if (user_access('edit issue')) { // The user may have defined "status|assigned|resolution" tags, // we thus strip them before proceeding. // Further, we enforce unique tags, i.e. no tag can appear more than once per node $tags = $form_state['values']['taxonomy']['tags'][$vid]; $tag_seq = explode(",", $tags); $tag_seq = array_map('trim', $tag_seq); // trim all entries. $tags_unique = array_unique($tag_seq); // retain unique entries // we now have a list of unique, trimmed strings (i.e. tags) $cleaned_tag_seq = array(); foreach ($tags_unique as $tag) { if ((strpos($tag, "status::") === 0) || (strpos($tag, "assigned::") === 0) || (strpos($tag, "resolution::") === 0)) { // it's a special tag: throw it away! } else { $cleaned_tag_seq[] = $tag; } } $form_state['values']['taxonomy']['tags'][$vid] = implode(",", $cleaned_tag_seq); if ($form_state['values']['resolution'] != '-') { $form_state['values']['issuestatus'] = 'status::closed'; } if ($form_state['values']['taxonomy']['tags'][$vid] == '') { $form_state['values']['taxonomy']['tags'][$vid] = $form_state['values']['issuestatus']; } else { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['issuestatus']; } if ($form_state['values']['assignedto'] != '-') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['assignedto']; } if ($form_state['values']['resolution'] != '-') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['resolution']; } if ($form_state['values']['subscription'] != '') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['subscription']; } } else { $form_state['values']['taxonomy']['tags'][$vid] = 'status::open'; } // this was in node_insert() $deadline = $form_state['values']['deadline']; if ($deadline != NULL) { $deadline_timestamp = gmmktime(0, 0, 0, $deadline['month'], $deadline['day'], $deadline['year']); } else { $deadline_timestamp = (int)0; } //add issue into backend // call is coming from the web interface -> issue has to be created in the backend $project_id = (int)variable_get('origo_project_id', 0); $issue_id = origo_auth_xmlrpc_session( variable_get('origo_api_internal', ''), 'internal_issue.add_2', (int)$project_id, $form_state['values']['title'], $form_state['values']['body'], $form_state['values']['taxonomy']['tags'][$vid], (bool)$form_state['values']['private'], (int)$deadline_timestamp, (int)$form_state['values']['work_amount']); if ($issue_id == 0) { // insertion in the backend failed // set message & abort $nb = xmlrpc_errno(); $xml_msg = xmlrpc_error_msg(); $msg = "Oops, insertion into the back-end failed - a node might be unavailable. Received error message: \"$xml_msg\" (Errorno $nb)
"; $msg .= "Try waiting a few minutes and submit the issue again."; form_set_error('title', $msg); } else { // We store the issue-id into the form-values, so it get's baked into the node as field in its object. // We will retrieve & process it later on in hook_insert(). $form_state['values']['issue_id'] = $issue_id; } } } /** * submit function for issues (form: 'issue_node_form') */ function issue_tracker_form_submit($form, &$form_state) { // The node must alread exist (i.e. it's being edited) if (isset($form['#node']) && isset($form['#node']->nid)) { // need edit permissions if (user_access('edit issue')) { $vid = _issue_tracker_get_issue_vocabulary_id(); // The user may have defined "status|assigned|resolution" tags, // we thus strip them before proceeding. // Further, we enforce unique tags, i.e. no tag can appear more than once per node $tags = $form_state['values']['taxonomy']['tags'][$vid]; $tag_seq = explode(",", $tags); $tag_seq = array_map('trim', $tag_seq); // trim all entries. $tags_unique = array_unique($tag_seq); // retain unique entries // we now have a list of unique, trimmed strings (i.e. tags) $cleaned_tag_seq = array(); foreach ($tags_unique as $tag) { if ((strpos($tag, "status::") === 0) || (strpos($tag, "assigned::") === 0) || (strpos($tag, "resolution::") === 0)) { // it's a special tag: throw it away! } else { $cleaned_tag_seq[] = $tag; } } $form_state['values']['taxonomy']['tags'][$vid] = implode(",", $cleaned_tag_seq); if ($form_state['values']['resolution'] != '-') { $form_state['values']['issuestatus'] = 'status::closed'; } if ($form_state['values']['taxonomy']['tags'][$vid] == '') { $form_state['values']['taxonomy']['tags'][$vid] = $form_state['values']['issuestatus']; } else { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['issuestatus']; } if ($form_state['values']['assignedto'] != '-') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['assignedto']; } if ($form_state['values']['resolution'] != '-') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['resolution']; } if ($form_state['values']['subscription'] != '') { $form_state['values']['taxonomy']['tags'][$vid] .= ', '. $form_state['values']['subscription']; } } else { $form_state['values']['taxonomy']['tags'][$vid] = 'status::open'; } } } /** * Implementation of hook_form_alter(). * We use this function only for hiding the taxonomy-field for non-project members. This has to be * done from here, as hook_form_FORM_ID_alter is called before taxonomy_form_alter will write its fields. */ function issue_tracker_form_alter(&$form, &$form_state) { if (!user_access('edit issue')) { $form['taxonomy']['tags'][_issue_tracker_get_issue_vocabulary_id()]['#type'] = 'hidden'; } } /** * Implementation of hook_form_FORM_ID_alter(). * @param $form * @param $form_state * @return unknown_type */ function issue_tracker_form_issue_node_form_alter(&$form, &$form_state) { $issue_body = ''; $issue_status = ''; $issuestatus_default = 'status::open'; $issue_assigned_to = '-'; $issue_resolution = '-'; $default_work_amount = 0; // set the default deadline to one week after today, 604800 = 7 * 24 * 60 * 60 $default_deadline = gmgetdate(time() + 604800); if ($form_state['values']['op'] == 'Preview') { // read previously submitted values $issue_body = $form_state['values']['body']; $issue_status = $form_state['values']['issuestatus']; $issue_assigned_to = $form_state['values']['assignedto']; $issue_resolution = $form_state['values']['resolution']; $default_work_amount = $form_state['values']['work_amount']; $default_deadline = array( 'year' => $form_state['values']['deadline']['year'], 'mon' => $form_state['values']['deadline']['month'], 'mday' => $form_state['values']['deadline']['day'], ); } else { $issue_body = "'''What steps will reproduce the problem?'''\r\n" . "* 1.\r\n" . "* 2.\r\n" . "* 3.\r\n" . "\r\n" . "'''What is the expected output? What do you see instead?'''\r\n" . "\r\n" . "\r\n" . "'''What version of the product are you using? On what operating system?'''\r\n" . "\r\n" . "\r\n" . "'''Please provide any additional information below:'''\r\n"; $issue_status = $issuestatus_default; } drupal_add_js('document.getElementById(\'edit-title\').focus()', 'inline', 'footer'); if ($form['nid']['#value']=='') { $form['body_filter']['body']['#default_value'] = $issue_body; } $form['#validate'][] = 'issue_tracker_form_validate'; if (user_access('edit issue')) { // include JavaScript for the "take" button global $user; drupal_add_js('var drupal_username = "'. $user->name .'";', 'inline'); drupal_add_js(drupal_get_path('module', 'issue_tracker') .'/js/edit_issue.js'); //Set weights $form['taxonomy']['#weight'] = 4; $form['body_filter']['#weight'] = 7; $form['title']['#weight'] = -5; $form['private']['#weight'] = 8; // Issue Status $form['issuestatus'] = array( '#prefix' => "
", '#type' => 'select', '#title' => t('Status'), '#options' => array('status::open' => t('Open'), 'status::closed' => t('Closed')), '#weight' => 2, '#default_value' => $issue_status, ); // Assigned to: // TODO: this is called also when previewing - and there we shouldn't have to load the values from the back-end. // Also, if the back-end's down, this will produce an "array merge" errormessage: // # warning: array_merge() [function.array-merge]: Argument #1 is not an array in /var/www/drupal/sites/all/modules/issue_tracker/issue_tracker.module on line 657. # warning: array_merge() [function.array-merge]: Argument #2 is not an array in /var/www/drupal/sites/all/modules/issue_tracker/issue_tracker.module on line 657. # warning: Invalid argument supplied for foreach() in /var/www/drupal/sites/all/modules/issue_tracker/issue_tracker.module on line 660. // bherlig, 2010-06-17 $members1 = origo_auth_xmlrpc_session(variable_get('origo_api', ''), 'project.members', variable_get('origo_project_id', 0), 3); $members2 = origo_auth_xmlrpc_session(variable_get('origo_api', ''), 'project.members', variable_get('origo_project_id', 0), 4); $members = array_merge($members1, $members2); $users = array('-' => t('-')); foreach ($members as $member) { $users['assigned::'. $member['name']] = t($member['name']); } asort($users); $form['assignedto'] = array( '#type' => 'select', '#title' => t('Assigned to'), '#options' => $users, '#weight' => 3, '#default_value' => $issue_assigned_to, ); $form['resolution'] = array( '#type' => 'select', '#title' => t('Resolution'), '#options' => array( '-' => t('-'), 'resolution::fixed' => t('Fixed'), 'resolution::wontfix' => t('Won\'t fix'), 'resolution::duplicate' => t('Duplicate'), 'resolution::worksforme' => t('Works for me') ), '#weight' => 4, '#default_value' => $issue_resolution, ); // load values from the backend, but only if it's not a preview (because the values might have been changed in the meantime) if ($form['nid']['#value'] != '' && $form_state['values']['op'] != 'Preview') { $nid = $form['nid']['#value']; $issue_query = db_query("SELECT issue_id FROM {issues} WHERE nid = %d", $nid); $issue = db_fetch_object($issue_query); $issue_data = origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.retrieve_planning_data', (int)variable_get('origo_project_id', 0), (int)$issue->issue_id); // get deadline from backend, if existing. otherwise, the default deadline as calculated above will be used. if ($issue_data['deadline'] != 0) { $default_deadline = gmgetdate($issue_data['deadline']); } $default_work_amount = $issue_data['work_amount']; } $form['deadline'] = array( '#type' => 'date', '#title' => t('Deadline'), '#description' => t('The date until the issue should be fixed'), '#default_value' => array('year' => $default_deadline['year'], 'month' => $default_deadline['mon'], 'day' => $default_deadline['mday']), '#weight' => 5, ); $work_amount_options = array(0 => '-'); for ($i = 1; $i <= 14; $i++) { $work_amount_options[$i] = $i; } $form['work_amount'] = array( '#type' => 'select', '#title' => t('Work amount'), '#options' => $work_amount_options, '#default_value' => $default_work_amount, '#description' => t('Estimated number of days needed to fix the issue'), '#weight' => 6, '#suffix' => "
", // end 'issue-edit' ); if ($form['nid']['#value'] != '' || $form_state['values']['op'] == 'Preview') { $vid = _issue_tracker_get_issue_vocabulary_id(); $subscription_tags = array(); foreach ($form['#node']->taxonomy as $id => $obj) { $tag = ''; if (is_array($obj)) { // when previewing, $obj is an array $tag = $obj[(String)$vid]; } else { // when editing an existing node, $obj is an stdClass object $tag = $obj->name; } if (strpos($tag, "status") === 0) { $form['issuestatus']['#default_value'] = $tag; unset($form['#node']->taxonomy[$id]); } elseif (strpos($tag, "assigned") === 0) { $form['assignedto']['#default_value'] = $tag; unset($form['#node']->taxonomy[$id]); } elseif (strpos($tag, "resolution") === 0) { $form['resolution']['#default_value'] = $tag; unset($form['#node']->taxonomy[$id]); } elseif (strpos($tag, "subscribed") === 0) { $subscription_tags[] = $tag; } } // replacement will be done in order of items in array // this order may NOT be the same as the numerical order of the keys $pattern[0] = '/(assigned|status|subscribed|resolution)::[a-z_]+(, )/'; $pattern[1] = '/(, )(assigned|status|subscribed|resolution)::[a-z_]+$/'; $pattern[2] = '/(assigned|status|subscribed|resolution)::[a-z_]+/'; $replacement[0] = ''; $replacement[1] = ''; $replacement[2] = ''; $form['taxonomy']['tags'][$vid]= preg_replace($pattern, $replacement, $form['taxonomy']['tags'][$vid]); $form['subscription'] = array( '#type' => 'hidden', '#value' => implode(', ', $subscription_tags) ); } } } /** * Implementation of hook_form_FORM_ID_alter(). * Overrides the node module's deletion confirmation form. */ function issue_tracker_form_node_delete_confirm_alter(&$form, &$form_state) { $node = node_load($form['nid']['#value']); if (_issue_tracker_delete_tab_enabled($node->type)) { drupal_set_title(t('Are you sure you want to delete issue "%title"?', array('%title' => $node->title))); $form['log'] = array( '#type' => 'textfield', '#title' => t('Log message'), '#default_value' => t('Deleted'), '#weight' => -1, ); $form['description']['#value'] = t('The issue and all related data (e.g. workitems) will be permanently deleted. This action cannot be undone.'); $form['actions']['submit']['#value'] = t('Delete'); unset($form['#submit']); $form['#submit'] = array('issue_tracker_node_delete_confirm_submit'); } } /** * Submit handler for the 'node_delete_confirm' form of the node module. * This is called instead of the original submit handler for issue nodes. */ function issue_tracker_node_delete_confirm_submit($form, &$form_state) { if ($form_state['values']['confirm']) { $node = node_load($form_state['values']['nid']); if (_issue_tracker_delete_tab_enabled($node->type)) { $node->log = $form_state['values']['log']; // Send call to backend $issue_id = _issue_id_from_nid($node->nid); origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.delete', (int)variable_get('origo_project_id', 0), (int)$issue_id); $nb = xmlrpc_errno(); $msg = xmlrpc_error_msg(); if (isset($nb)) { drupal_set_message('Error deleting issue: '. $msg .' (Errorno '. $nb .')'); } else { // delete the node node_delete($form_state['values']['nid']); } // redirect to issue overview $form_state['redirect'] = 'issues'; } else { // Call original submit handler node_delete_confirm_submit($form, $form_state); } } } /** * Implementation of hook_update(). * Called upon updating an issue. */ function issue_tracker_update($node) { if (! isset($node->source) || $node->source != "api") { // call is coming from the web (frontend) $title = $node->title; $description = $node->body; $tags = $node->taxonomy['tags'][_issue_tracker_get_issue_vocabulary_id()]; $private = (bool)$node->private; $deadline = $node->deadline; $deadline_timestamp = gmmktime(0, 0, 0, $deadline['month'], $deadline['day'], $deadline['year']); $work_amount = (int)$node->work_amount; $issue_id = 0; $result = db_query("SELECT issue_id FROM {issues} WHERE nid=%d", $node->nid); $issue_id = db_fetch_array($result); // Update issue origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.update_2', (int)variable_get('origo_project_id', 0), (int)$issue_id['issue_id'], $title, $description, $tags, (bool)$private, $deadline_timestamp, $work_amount); if ((int)xmlrpc_errno() > 0) { $msg = t('Failed to update the issue in the back-end, response was: '); $msg .= t(xmlrpc_error_msg()); $msg .= ' (Error #'. xmlrpc_errno() .')'; drupal_set_message($msg, 'warning'); } $node->issue_id = $issue_id['issue_id']; } } /** * Implementation of hook_insert(). */ function issue_tracker_insert($node) { db_query("INSERT INTO {issues} (nid, issue_id) VALUES (%d, %d)", $node->nid, $node->issue_id); } /** * Implementation of hook_delete(). * Removes issue-data from issue-tracker specific DB tables. */ function issue_tracker_delete($node) { db_query("DELETE FROM {issues} WHERE nid = %d", $node->nid); } function issue_tracker_nodeapi(&$node, $op, $teaser) { global $_SESSION; switch ($op) { case 'insert': if (isset($node->files)) { $issue_id = $node->issue_id; foreach ($node->files as $fid => $file) { $file = (object)$file; $pos = strrpos($file->filepath, '/'); $filename = substr($file->filepath, $pos + 1); $attachment_id = origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.add_attachment', (int)variable_get('origo_project_id', 0), (int)$issue_id, $filename, "(no description)"); } } break; case 'update': if (isset($node->files)) { $issue_id = $node->issue_id; $attachments = origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.retrieve_attachments', (int)variable_get('origo_project_id', 0), (int)$issue_id); // add missing attachments and remove needless attachments if (isset($node->files) && sizeof($node->files) > 0) { foreach ($node->files as $fid => $file) { $file = (object)$file; $pos = strrpos($file->filepath, '/'); $filename = substr($file->filepath, $pos + 1); if (!attachments_contain_file($attachments, $filename)) { //the file is not in the list of attachments saved in the origo db origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.add_attachment', (int)variable_get('origo_project_id', 0), (int)$issue_id, $filename, "(no description)"); } elseif ($file->remove) { //the file has to be removed origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.remove_attachment', (int)variable_get('origo_project_id', 0), (int)$issue_id, $filename); } } } elseif (($attachments != FALSE) && sizeof($attachments) > 0) { // there are entries in $attachments, but $node does not have them anymore // -> they were all removed foreach ($attachments as $a) { origo_auth_xmlrpc_session(variable_get('origo_api_internal', ''), 'internal_issue.remove_attachment', (int)variable_get('origo_project_id', 0), (int)$issue_id, $a['file_name']); } } } break; } } /** * Menu callback for path 'issues' (issues page) */ function issues_page($filter = NULL) { global $user; // load the issues-per-page setting via origo_home module_load_include('inc', 'origo_home', 'origo_constants'); module_load_include('inc', 'origo_home', 'origo_settings'); $post_per_page = get_origo_setting(ORIGO_ISSUE_AMOUNT, 25, $user->name); drupal_add_css(drupal_get_path('module', 'issue_tracker') .'/issue_tracker.css'); // load js for base64 en/decoding for configuring filters or personalized filter-buttons // This is only needed for members/owners/admins if(user_access('edit issue')) { load_webtoolkit_base64(); } $stats = build_issue_stats_bar(); $data = issue_data_provider(); $header = $data['header']; $rows = $data['rows']; $output .= _issue_tracker_build_issue_header(); $output .= theme('table', array(), $stats, array('style' => 'width:100%')); $output .= '
'; $filters = personal_filters_provider($filter); $output .= theme('table', NULL, $filters['rows'], array('style' => 'width:100%')); $output .= "

"; drupal_add_js(drupal_get_path('module', 'issue_tracker') .'/issue_tracker.js'); drupal_add_js('misc/collapse.js'); drupal_add_js('misc/progress.js'); $output .= build_filter(); $output .= '
'; $output .= theme('issue_tracker_pager', $post_per_page); $output .= theme('table', $header, $rows, array('style' => 'width:100%')); $output .= theme('issue_tracker_pager', $post_per_page); $output .= '

'; return $output; } /** * Utility function for calculating data for the "open/closed" bar on top of the main issue tracker page. */ function build_issue_stats_bar() { $result = db_query(' SELECT t.name, COUNT(*) as count FROM (SELECT tid, name FROM {term_data} WHERE name LIKE "status::%") t JOIN {term_node} tn ON t.tid = tn.tid JOIN {issues} i ON tn.nid = i.nid GROUP BY t.name ORDER BY t.name ASC'); $open = 0; $closed = 0; $undefined = 0; while ($obj = db_fetch_object($result)) { switch ($obj->name) { case "status::open": $open = $obj->count; break; case "status::closed": $closed = $obj->count; break; default: $undefined = $obj->count; } } $total = ($open + $closed + $undefined)/100; $statrow = array(); if ($open>0) { $statrow[] = array('data' => t("open ($open)"), 'style' => 'background-color:#FFBBBB; white-space:nowrap; width:'. $open/$total .'%'); } if ($undefined>0) { $statrow[] = array('data' => t("undefined ($undefined)"), 'style' => 'background-color:#DDDDDD; white-space:nowrap; width:'. $undefined/$total .'%'); } if ($closed>0) { $statrow[] = array('data' => t("closed ($closed)"), 'style' => 'background-color:#BBFFBB; white-space:nowrap; width:'. $closed/$total .'%'); } return array($statrow); } function build_filter() { $output = ""; // drag-n-drop only for owners if(user_access('origo project settings')) { jquery_ui_add(array('ui.sortable', 'ui.draggable', 'ui.droppable')); drupal_add_js(drupal_get_path('module', 'issue_tracker') .'/js/edit_filters.js'); } $filter_order = variable_get('issue_tracker_filter_order', NULL); $tags = retrieve_tags($filter_order); // prepare static filters $filters = array(); $filters = array_merge($filters, issue_tracker_status_filter()); $filters += issue_tracker_resolution_filter(); $filters += issue_tracker_visibility_filter(); $filters += issue_tracker_reporter_filter(); // Check if any of the special filters above are currently hidden, // and add their tags to the general filter box, if necessary $hidden_filters = array(); if (isset($filter_order) && is_array($filter_order)) { $special_filters = array( 'filter-c3RhdHVz', // status 'filter-cmVzb2x1dGlvbg__', // resolution 'filter-dmlzaWJpbGl0eQ__', // visibility 'filter-cmVwb3J0ZXI_', // reporter ); foreach ($special_filters as $f) { if (!in_array($f, $filter_order)) { // This filter is currently hidden // move the tags, but with the full name foreach($filters[$f]['options'] as $k => $v) { $tags['regular'][$k] = $k; } // create an entry in the "hidden tags" list, // this will be processed further down (and put the filter into the "hidden panel") $tags['hidden'][$f] = array(); } } // resort tags ksort($tags['regular']); } // build general "Tags" filter $filters = array_merge($filters, issue_tracker_tags_filter($tags['regular'], 'tags')); // add dynamic filters (#995) foreach ($tags['separated'] as $prefix => $filter) { $filters += issue_tracker_tags_filter($filter, $prefix); } // process hidden tags foreach ($tags['hidden'] as $prefix => $filter) { $filters[$prefix]['options'] = array(); $hidden_filters[] = $filters[$prefix]; unset($filters[$prefix]); } // Enforce filter order // This will take care of hidden filters as well (by not adding them). $ordered_filters = array(); if (!isset($filter_order)) { // This variable was retrieved from Drupal, if it's null, then the filters were never configured. $filter_order = array_keys($filters); } // now we build the list of visible filters foreach ($filter_order as $filter_name) { if (array_key_exists($filter_name, $filters)) { $ordered_filters[] = $filters[$filter_name]; unset($filters[$filter_name]); } } // Put the remaining filters into the hidden panel // Note: this shouldn't happen (ever), as all filters should have been sorted in the code above $hidden_filters += $filters; // free-filters $free_filters = array(); $free_filters[] = issue_tracker_text_filter(); global $user; if ($user->uid > 0) { $free_filters[] = issue_tracker_save_filter(); } $output .= '
Filter'; if(user_access('origo project settings')) { $output .= theme('issue_tracker_hidden_filter', $hidden_filters, 'hidden'); } $output .= theme('issue_tracker_filter', $ordered_filters, 'main'); $output .= theme('issue_tracker_free_filter', $free_filters); // only show configure link for project owners if(user_access('origo project settings')) { $output .= "

Configure Filters

"; } $output .= "
"; return $output; } function issue_tracker_get_closed_status($issue_id) { // return if this issue is 'closed' or not // get tags $n->nid = _nid_from_issue_id($issue_id); $node_revs = node_revision_list($n); $keys = array_keys($node_revs); $latest_revision = $node_revs[$keys[0]]; $n->vid = $latest_revision->vid; $vid = _issue_tracker_get_issue_vocabulary_id(); $tags = taxonomy_node_get_terms_by_vocabulary($n, $vid); foreach ($tags as $t) { if ($t->name == 'status::closed') { $status = True; break; } if ($t->name == 'status::true') { break; } } drupal_json( array( 'issueClosedStatus' => $status, ) ); } /** * Internal XML-RPC interface definition * * Defines mapping from xml-rpc methods to drupal functions. * These functions are only called by the backend and NOT by drupal itself. */ function issue_tracker_xmlrpc() { return array( array( 'issue.add', // xml-rpc method '_issue_add', // drupal function array( boolean, //return value (status) int, // issue_id string, // user name string, // title string, // description string, // tags boolean // is_private ), t('Adds a new issue in the Drupal database') ), array( 'issue.update', '_issue_update', array( boolean, // return value (status) int, // project issue id string, // title string, // description string, // tags boolean // is_private ), t('Updates an existing issue in the Drupal database') ), array( 'issue.comment', '_issue_comment', array( boolean, // return value (status) int, // project issue id string, // user name string, // description string // tags ), t('Adds an issue reply') ), array( 'issue.add_attachment', '_issue_add_attachment', array( boolean, // return value (status) int, // project issue id string, // filename string, // filepath string // description ), t('Adds a new file attachment in the Drupal database') ), array( 'issue.remove_attachment', '_issue_remove_attachment', array( boolean, // return value (status) int, // project issue id string, // filename ), t('Removes a file attachment from the Drupal database') ), array( 'issue.get_unique_file_name', '_issue_get_unique_file_name', array( string, //return value (unique file name) string //name of a file which will be uploaded ), t('Creates a unique file name for a file that will be uploaded') ), array( 'issue.delete', '_issue_delete', array( boolean, // return value (status) int, // project issue id ), t('Deletes an issue from Drupal'), ), ); }