/[drupal]/contributions/modules/project_issue/project_issue.module
ViewVC logotype

Contents of /contributions/modules/project_issue/project_issue.module

Parent Directory Parent Directory | Revision Log Revision Log | View Revision Graph Revision Graph


Revision 1.174 - (show annotations) (download) (as text)
Thu Aug 6 23:35:34 2009 UTC (3 months, 3 weeks ago) by dww
Branch: MAIN
CVS Tags: DRUPAL-6--1-0-ALPHA3, HEAD
Changes since 1.173: +70 -1 lines
File MIME type: text/x-php
#229063 by dww, mikehostetler, jeffschuler, greggles: Added token support.
1 <?php
2 // $Id: project_issue.module,v 1.173 2009/06/18 03:30:22 dww Exp $
3
4 // issue nodes -> project_issues
5 // issue comments -> project_issue_comments
6
7 /// Default age in days of issues to auto close.
8 define('PROJECT_ISSUE_AUTO_CLOSE_DAYS', 14);
9 /// Project issue state = fixed.
10 define('PROJECT_ISSUE_STATE_FIXED', 2);
11 /// Project issue state = closed.
12 define('PROJECT_ISSUE_STATE_CLOSED', 7);
13
14 /**
15 * Implementation of hook_init().
16 */
17 function project_issue_init() {
18 /// @TODO: we need a real page split instead of this.
19 module_load_include('inc', 'project_issue', 'issue');
20 foreach (array('comment', 'mail') as $file) {
21 module_load_include('inc', 'project_issue', "includes/$file");
22 }
23 /// @TODO: this should only be done on pages that need it.
24 $path = drupal_get_path('module', 'project_issue');
25 drupal_add_css($path .'/project_issue.css');
26 }
27
28 function project_issue_menu() {
29 $items = array();
30
31 $includes = drupal_get_path('module', 'project_issue') .'/includes';
32
33 // Issues
34 $items['project/issues/update_project'] = array(
35 'page callback' => 'project_issue_update_project',
36 'access callback' => 'project_issue_menu_access',
37 'access arguments' => array('any'),
38 'type' => MENU_CALLBACK,
39 );
40 $items['project/issues/statistics'] = array(
41 'title' => 'Statistics',
42 'page callback' => 'project_issue_statistics',
43 'access callback' => 'project_issue_menu_access',
44 'access arguments' => array('any'),
45 'type' => MENU_NORMAL_ITEM,
46 'file' => 'includes/statistics.inc',
47 );
48 $items['project/issues/statistics/%project_node'] = array(
49 'title' => 'Statistics',
50 'page callback' => 'project_issue_statistics',
51 'page arguments' => array(3),
52 'access callback' => 'project_issue_menu_access',
53 'access arguments' => array('any'),
54 'type' => MENU_NORMAL_ITEM,
55 'file' => 'includes/statistics.inc',
56 );
57 $path = 'project/issues/subscribe-mail';
58 if (!variable_get('project_issue_global_subscribe_page', TRUE)) {
59 // If we don't want the global subscribe page, require an argument.
60 $path .= '/%';
61 }
62 $items[$path] = array(
63 'title' => 'Subscribe',
64 'page callback' => 'drupal_get_form',
65 'page arguments' => array('project_issue_subscribe', 3),
66 'access callback' => 'project_issue_menu_access',
67 'access arguments' => array('auth'),
68 'type' => MENU_NORMAL_ITEM,
69 'file' => 'includes/subscribe.inc',
70 );
71 if (module_exists('search')) {
72 $items['search/issues'] = array(
73 'title' => 'Issues',
74 'page callback' => 'project_issue_search_page',
75 'access callback' => 'project_issue_menu_access',
76 'access arguments' => array('any'),
77 'type' => MENU_LOCAL_TASK,
78 'weight' => 4,
79 );
80 }
81
82 // "My projects" page (which shows all issues for all your projects)
83 $items['project/user'] = array(
84 'title' => 'My projects',
85 'page callback' => 'project_issue_user_page',
86 'access callback' => 'project_issue_menu_access',
87 'access arguments' => array('auth'),
88 'type' => MENU_NORMAL_ITEM,
89 'weight' => -49,
90 );
91
92 // Administrative pages
93 $items['admin/project/project-issue-settings'] = array(
94 'title' => 'Project issue settings',
95 'description' => 'Specify where attachments to issues should be stored on your site, and what filename extensions should be allowed.',
96 'page callback' => 'drupal_get_form',
97 'page arguments' => array('project_issue_settings_form'),
98 'access arguments' => array('administer projects'),
99 'weight' => 1,
100 'type' => MENU_NORMAL_ITEM,
101 'file' => 'includes/admin.settings.inc',
102 );
103
104 // Administer issue status settings
105 $items['admin/project/project-issue-status'] = array(
106 'title' => 'Project issue status options',
107 'description' => 'Configure what issue status values should be used on your site.',
108 'page callback' => 'drupal_get_form',
109 'page arguments' => array('project_issue_admin_states_form'),
110 'access arguments' => array('administer projects'),
111 'type' => MENU_NORMAL_ITEM,
112 'weight' => 1,
113 'file' => 'includes/admin.issue_status.inc'
114 );
115 $items['admin/project/project-issue-status/delete'] = array(
116 'title' => 'Delete',
117 'page callback' => 'drupal_get_form',
118 'page arguments' => array('project_issue_delete_state_confirm', 4),
119 'access arguments' => array('administer projects'),
120 'type' => MENU_CALLBACK,
121 'file' => 'includes/admin.issue_status.inc'
122 );
123
124 // Issues subtab on project node edit tab.
125 $items['node/%project_node/edit/issues'] = array(
126 'title' => 'Issues',
127 'page callback' => 'project_issue_project_edit_issues',
128 'page arguments' => array(1),
129 'access callback' => 'node_access',
130 'access arguments' => array('update', 1),
131 'type' => MENU_LOCAL_TASK,
132 'file' => 'includes/project_edit_issues.inc',
133 );
134 $items['node/%project_node/edit/component/delete/%'] = array(
135 'title' => 'Delete component',
136 'description' => 'Delete component',
137 'page callback' => 'drupal_get_form',
138 'page arguments' => array('project_issue_component_delete_form', 1, 5),
139 'access callback' => 'node_access',
140 'access arguments' => array('update', 1),
141 'type' => MENU_CALLBACK,
142 'file' => 'includes/project_edit_issues.inc',
143 );
144
145 $items['node/add/project-issue/%'] = array(
146 'page callback' => 'node_add',
147 'page arguments' => array('project-issue'),
148 'title' => drupal_ucfirst(node_get_types('name', 'project_issue')),
149 'title callback' => 'check_plain',
150 'access callback' => 'node_access',
151 'access arguments' => array('create', 'project_issue'),
152 'page callback' => 'node_add',
153 'page arguments' => array(2),
154 'file' => 'node.pages.inc',
155 'file path' => drupal_get_path('module', 'node'),
156 'type' => MENU_CALLBACK,
157 );
158 // Redirect node/add/project_issue/* to node/add/project-issue.
159 $items['node/add/project_issue'] = array(
160 'page callback' => 'project_issue_add_redirect_page',
161 'page arguments' => array(3, 4),
162 'access callback' => 'node_access',
163 'access arguments' => array('create', 'project_issue'),
164 'file' => 'includes/issue_node_form.inc',
165 'type' => MENU_CALLBACK,
166 );
167
168 // Autocomplete paths.
169
170 // Autocomplete a comma-separated list of projects that have issues enabled.
171 $items['project/autocomplete/issue/project'] = array(
172 'page callback' => 'project_issue_autocomplete_issue_project',
173 'access callback' => 'project_issue_menu_access',
174 'access arguments' => array('any'),
175 'file' => 'autocomplete.inc',
176 'file path' => $includes,
177 'type' => MENU_CALLBACK,
178 );
179
180 // Autocomplete a comma-separated list of projects from all issues a user
181 // has either submitted or commented on.
182 $items['project/autocomplete/issue/user/%'] = array(
183 'page callback' => 'project_issue_autocomplete_user_issue_project',
184 'page arguments' => array(4, 5),
185 'access callback' => 'project_issue_menu_access',
186 'access arguments' => array('any'),
187 'file' => 'autocomplete.inc',
188 'file path' => $includes,
189 'type' => MENU_CALLBACK,
190 );
191
192 return $items;
193 }
194
195 /**
196 * Implementation of hook_menu_alter().
197 */
198 function project_issue_menu_alter(&$callbacks) {
199 // Special menu item for the "first page" of submitting a new issue.
200 // Instead of the treachery of a true multipage form, we just have
201 // a simple form at node/add/project-issue that provides a project
202 // selector which redirects to node/add/project-issue/[project-name].
203 $callbacks['node/add/project-issue']['page callback'] = 'project_issue_pick_project_page';
204 $callbacks['node/add/project-issue']['file'] = 'issue_node_form.inc';
205 $callbacks['node/add/project-issue']['file path'] = drupal_get_path('module', 'project_issue') . '/includes';
206 }
207
208 /**
209 * Determine access to a given type of menu item.
210 *
211 * @param $type
212 * Type of menu item to check access for, can be 'any' if the current user
213 * can access any issues, or 'auth' if the current user is authenticated and
214 * can accses any issues.
215 */
216 function project_issue_menu_access($type) {
217 global $user;
218 if ($type == 'auth' && empty($user->uid)) {
219 return FALSE;
220 }
221 return user_access('access project issues') || user_access('access own project issues');
222 }
223
224 function project_issue_help($path, $arg) {
225 switch ($path) {
226 case 'admin/help#project_issue':
227 return '<h3>'. t('Mailhandler support') .'</h3>'.
228 '<p>'. t('Basic mail format:') .'</p>'.
229 '<pre>'. t("Type: project\nProject: chatbox\nCategory: bug report\nVersion: cvs\nPriority: normal\nStatus: active\nComponent: code\n\nWhatever I type here will be the body of the node.\n") .'</pre>'.
230 '<p>'. t('See the mailhandler help for more information on using the mailhandler module.') .'</p>';
231 case 'node/add#project_issue':
232 return t('Add a new issue (bug report, feature request, etc) to an existing project.');
233 case 'admin/project/project-issue-status':
234 return '<p>'. t('Use this page to add new status options for project issues or to change or delete existing options.') .'</p>'.
235 '<dl>'.
236 '<dt>'. t('Adding') .'</dt>'.
237 '<dd>'. t('To add a new status option, put its name in one of the blank places at the bottom of the form and assign it a weight.') .'</dd>'.
238 '<dt>'. t('Updating') .'</dt>'.
239 '<dd>'. t('When renaming existing issues, keep in mind that issues with the existing name will receive the new one.') .'</dd>'.
240 '<dt>'. t('Deleting') .'</dt>'.
241 '<dd>'. t('If you delete an existing issue status, you will be prompted for a new status to assign to existing issues with the deleted status.') .'</dd>'.
242 '<dt>'. t('Weight') .'</dt>'.
243 '<dd>'. t('The weight of an issue determines the order it appears in lists, like in the select box where users designate a status for their issue.') .'</dd>'.
244 '<dt>'. t('Author may set') .'</dt>'.
245 '<dd>'. t("Check this option to give the original poster of an issue the right to set a status option, even if she or he isn't part of a role with this permission. You may wish, for example, to allow issue authors to close their own issues.") .'</dd>'.
246 '<dt>'. t('In default queries') .'</dt>'.
247 '<dd>'. t('There are a number of pages that display a list of issues based on a certain query. For all of these views of the issue queues, if no status options are explicitly selected, a certain set of defaults will be used to construct the query.') .'</dd>'.
248 '<dt>'. t('Default status') .'</dt>'.
249 '<dd>'. t('The default status option will be used for new issues, and all users with the permission to create issues will automatically have permission to set this status. The default issue status cannot be deleted. If you wish to delete this status, first set a different status to default.') .'</dd>'.
250 '</dl>';
251
252 }
253
254 // NOTE: This totally sucks, and is a dirty, ugly hack. Since we don't
255 // want to rely on PHP filtered headers for our views, and defining our
256 // own display plugin breaks other nice things like RSS, we just cheat
257 // and render these links in here. We'd like to remove this once a
258 // better solution is available that doesn't hard-code the paths.
259 if ($arg[0] == 'project' && $arg[1] == 'user') {
260 return project_issue_my_projects_table();
261 }
262
263 if ($arg[0] == 'project' && $arg[1] == 'issues') {
264 // If there's no other arg, we're done.
265 if (empty($arg[2])) {
266 return project_issue_query_result_links();
267 }
268 // project/issues/user is a special case, since if there's an argument,
269 // it's a username, not a project. Furthermore, we don't want any links
270 // for anonymous.
271 if ($arg[2] == 'user') {
272 global $user;
273 if (empty($user->uid) && empty($arg[3])) {
274 return;
275 }
276 return project_issue_query_result_links();
277 }
278 switch ($arg[2]) {
279 case 'search':
280 case 'statistics':
281 case 'subscribe-mail':
282 return project_issue_query_result_links($arg[3]);
283
284 default:
285 return project_issue_query_result_links($arg[2]);
286 }
287 }
288
289 }
290
291 /**
292 * Implementation of hook_theme().
293 */
294 function project_issue_theme() {
295 return array(
296 'project_issue_comment_table' => array(
297 'file' => 'includes/comment.inc',
298 'arguments' => array(
299 'comment_changes' => NULL,
300 ),
301 ),
302 'project_issue_comment_table_row' => array(
303 'file' => 'includes/comment.inc',
304 'arguments' => array(
305 'field' => NULL,
306 'change' => NULL,
307 ),
308 ),
309 'project_issue_subscribe' => array(
310 'file' => 'issue.inc',
311 'arguments' => array(
312 'form' => NULL,
313 ),
314 ),
315 'project_issue_summary' => array(
316 'file' => 'includes/issue_node_view.inc',
317 'arguments' => array(
318 'current_data' => NULL,
319 'summary_links' => NULL,
320 ),
321 ),
322 'project_issue_admin_states_form' => array(
323 'file' => 'includes/admin.issue_status.inc',
324 'arguments' => array(
325 'form' => NULL,
326 ),
327 ),
328 'project_issue_project_edit_form' => array(
329 'file' => 'includes/project_edit_issues.inc',
330 'arguments' => array(
331 'form' => NULL,
332 ),
333 ),
334 'project_issue_query_result_links' => array(
335 'file' => 'issue.inc',
336 'arguments' => array(
337 'links' => NULL,
338 ),
339 ),
340 'project_issue_create_forbidden' => array(
341 'file' => 'issue.inc',
342 'arguments' => array(
343 'uri' => NULL,
344 ),
345 ),
346 'project_issue_mail_summary' => array(
347 'file' => 'includes/mail.inc',
348 'arguments' => array(
349 'entry' => NULL,
350 'node' => NULL,
351 'changes' => NULL,
352 'display_files' => NULL,
353 ),
354 ),
355 'project_issue_mail_summary_field' => array(
356 'file' => 'includes/mail.inc',
357 'arguments' => array(
358 'node' => NULL,
359 'field_name' => NULL,
360 'change' => NULL,
361 ),
362 ),
363 'project_issue_auto_close_message' => array(
364 'file' => 'project_issue.module',
365 'arguments' => array(
366 'auto_close_days' => NULL,
367 ),
368 ),
369 'project_issue_issue_link' => array(
370 'file' => 'project_issue.module',
371 'arguments' => array(
372 'node' => NULL,
373 'comment_id' => NULL,
374 'comment_number' => NULL,
375 'include_assigned' => FALSE,
376 ),
377 ),
378 'project_issue_issue_cockpit' => array(
379 'arguments' => array(
380 'node' => NULL,
381 ),
382 'file' => 'includes/issue_cockpit.inc',
383 'template' => 'theme/project-issue-issue-cockpit',
384 ),
385 );
386 }
387
388 /**
389 * Implementation of hook_views_api().
390 */
391 function project_issue_views_api() {
392 return array(
393 'api' => 2.0,
394 'path' => drupal_get_path('module', 'project_issue') .'/views',
395 );
396 }
397
398 /**
399 * Implementation of hook_form_alter.
400 */
401 function project_issue_form_alter(&$form, &$form_state, $form_id) {
402 switch ($form_id) {
403 // Issues must be updated if any project issue comments are edited/deleted.
404 case 'comment_admin_overview':
405 $form['#submit'][] = 'project_issue_comment_mass_update';
406 break;
407
408 case 'project_issue_node_form':
409 // For our theming, wrap everything in a 'project-issue' class.
410 $form['#prefix'] = isset($form['#prefix'])? $form['#prefix'] : '';
411 $form['#prefix'] .= '<div class="project-issue">';
412 $form['#suffix'] = isset($form['#suffix'])? $form['#suffix'] : '';
413 $form['#suffix'] = '</div>'. $form['#suffix'];
414
415 if (isset($form['attachments'])) {
416 if (isset($form['project_info'])) {
417 // We already know what project it is, so make sure the 'File
418 // attachments' fieldset is expanded.
419 $form['attachments']['#collapsed'] = FALSE;
420 }
421 else {
422 // On the first page of the multi-page form, don't have a project
423 // selected yet, so unset the file attachments fieldset entirely.
424 unset($form['attachments']);
425 }
426 }
427 break;
428
429 // see also: project_issue_form_comment_form_alter
430 case 'comment_form':
431 $nid = $form['nid']['#value'];
432 $node = node_load($nid);
433
434 // Allows only project_issue
435 if ($node->type != 'project_issue') {
436 return;
437 }
438 // Make sure the 'File attachments' fieldset is expanded and before the
439 // original issue fieldset.
440 if (isset($form['attachments'])) {
441 $form['attachments']['#collapsed'] = FALSE;
442 $form['attachments']['#weight'] = 2;
443 // TODO: temporary hack until we decide how to deal with
444 // editing attachments on issues overall.
445 if (!empty($form['cid']['#value'])) {
446 unset($form['attachments']);
447 }
448 }
449 // Add our own custom validation to the comment form for issue nodes.
450 $form['#validate'][] = 'project_issue_form_comment_validate';
451 break;
452
453 case 'comment_confirm_delete':
454 $type = db_result(db_query("SELECT type FROM {node} WHERE nid = %d", $form['#comment']->nid));
455 if (!empty($type) && $type == 'project_issue') {
456 $form['description']['#value'] = t('This action cannot be undone.');
457 }
458 break;
459
460 case 'views_exposed_form':
461 project_issue_alter_views_exposed_form($form, $form_state);
462 break;
463
464 case 'project_issue_issue_cockpit_searchbox':
465 // Since we're using a GET #action for this searchbox, unset the FAPI
466 // cruft we don't want to see in the URL.
467 unset($form['form_build_id']);
468 unset($form['form_id']);
469 unset($form['form_token']);
470 break;
471 }
472 }
473
474 function project_issue_node_info() {
475 return array(
476 'project_issue' => array(
477 'name' => t('Issue'),
478 'module' => 'project_issue',
479 'description' => t('An issue that can be tracked, such as a bug report, feature request, or task.'),
480 ),
481 );
482 }
483
484 function project_issue_perm() {
485 $perms = array(
486 'create project issues',
487 'access project issues',
488 'edit own project issues',
489 'access own project issues',
490 'assign and be assigned project issues',
491 );
492 $states = project_issue_state();
493 foreach($states as $key => $value) {
494 $perms[] = "set issue status ". str_replace("'", "", $value);
495 }
496 return $perms;
497 }
498
499 function project_issue_access($op, $node, $account) {
500
501 if (user_access('administer projects', $account)) {
502 return TRUE;
503 }
504 switch ($op) {
505 case 'view':
506 if (user_access('access own project issues', $account) && $node->uid == $account->uid) {
507 return TRUE;
508 }
509 if (!user_access('access project issues', $account)) {
510 return FALSE;
511 }
512 break;
513 case 'create':
514 return user_access('create project issues', $account);
515 case 'update':
516 if (user_access('edit own project issues', $account) && $node->uid == $account->uid) {
517 return TRUE;
518 }
519 break;
520 case 'delete':
521 // Admin case already handled, no one else should be able to delete.
522 break;
523 }
524 }
525
526 /**
527 * Helper to trim all elements in an array.
528 */
529 function project_issue_trim(&$item, $key) {
530 $item = trim($item);
531 }
532
533 /**
534 * Implementation of hook_cron().
535 *
536 * There is a variable (no admin UI, just via settings.php) that controls if
537 * the admin has setup a separate cron job on their system to invoke this code
538 * instead of relying on cron.php and hook_cron(). If this variable, called
539 * 'project_issue_hook_cron', is set to FALSE, then there's nothing to do
540 * in here. Otherwise, we include the cron.inc file and invoke that code
541 * ourselves.
542 */
543 function project_issue_cron() {
544 if (variable_get('project_issue_hook_cron', TRUE)) {
545 module_load_include('inc', 'project_issue', 'includes/cron');
546 _project_issue_cron();
547 }
548 }
549
550 /**
551 * Comment left when cron auto-closes an issue.
552 *
553 * @param $auto_close_days
554 * The (minimum) number of days without activity before automatically closing
555 * a fixed issue.
556 * @return
557 * Message to be added as a comment to an issue when auto-closing that issue.
558 */
559 function theme_project_issue_auto_close_message($auto_close_days) {
560 $auto_close_interval = format_interval($auto_close_days * 24 * 60 * 60, 2);
561 return t('Automatically closed -- issue fixed for !interval with no activity.', array('!interval' => $auto_close_interval));
562 }
563
564 /**
565 * Add a followup to a project issue using the auto-followup user.
566 *
567 * @param $changes
568 * An associative array specifying what should change in the issue. Every key
569 * corresponds to a database field and the value is what it should be changed
570 * to. Required keys are:
571 * - nid: Specifies the issue being changed.
572 * - comment: Contains the text of the followup changing the issue.
573 * See project_issue_add_followup() for a full list of possible keys.
574 *
575 * @return
576 * TRUE if the comment was successfully added, FALSE if either there's no
577 * auto-followup user configured or if the requested issue wasn't found.
578 *
579 * @see project_issue_add_followup().
580 */
581 function project_issue_add_auto_followup($changes) {
582 // If a user for automatic followups exists, use that uid and proceed.
583 if ($auto_user = _project_issue_followup_get_user()) {
584 $changes['uid'] = $auto_user->uid;
585 return project_issue_add_followup($changes);
586 }
587 else {
588 return FALSE;
589 }
590 }
591
592 /**
593 * Saves a comment to the database.
594 *
595 * TODO: Ideally this should die as soon as core's comment_save() becomes more
596 * abstracted.
597 *
598 * @param $changes
599 * An associative array specifying what should change in the issue. Every key
600 * corresponds to a database field and the value is what it should be changed
601 * to. Required keys are:
602 * - nid: Specifies the issue being changed.
603 * - comment: Contains the text of the followup changing the issue.
604 *
605 * 'uid' and 'name' are optional keys -- if not specified then the values
606 * from the currently logged in user will be used, though it's generally
607 * safer to specify the uid explicitly.
608 *
609 * You can specify the following fields of the comment table: subject,
610 * hostname, timestamp, score, status, format, thread, mail, homepage.
611 * You can also specify the following fields from project_issues
612 * table: category, priority, assigned, sid, title. There is a special,
613 * optional key called 'project_info', its value is another associative
614 * array with the following fields from project_issues: pid, rid, component.
615 * Example: To change the issue status and set the comment text for the
616 * issue with nid = 100, this array might look like:
617 * array(
618 * 'nid' => 100,
619 * 'sid' => 4,
620 * 'comment' => t('This issue was automatically closed after 2 weeks of no activity.'),
621 * );
622 *
623 * @return
624 * TRUE if the comment was successfully added to the requested issue,
625 * otherwise FALSE.
626 */
627 function project_issue_add_followup($changes) {
628 if (isset($changes['uid'])) {
629 $account = user_load(array('uid' => $changes['uid']));
630 }
631 else {
632 global $user;
633 $account = $user;
634 }
635
636 $result = db_query('SELECT pi.nid, pi.rid, pi.component, pi.category, pi.priority, pi.assigned, pi.sid, pi.pid, n.title FROM {project_issues} pi INNER JOIN {node} n ON n.nid = pi.nid WHERE n.nid = %d', $changes['nid']);
637
638 if ($issue = db_fetch_object($result)) {
639 // Build vancode
640 $max = db_result(db_query('SELECT MAX(thread) FROM {comments} WHERE nid = %d', $changes['nid']));
641 // Strip the "/" from the end of the thread.
642 $max = rtrim($max, '/');
643 // Finally, build the thread field for this new comment.
644 $thread = int2vancode(vancode2int($max) + 1) .'/';
645
646 // These two are not allowed to be set in changes.
647 unset($changes['cid'], $changes['pid']);
648 $comment = $changes + array(
649 'pid' => 0,
650 'uid' => $account->uid,
651 // The correct subject (#number) is supplied during the save cycle.
652 'subject' => '--project followup subject--',
653 'hostname' => ip_address(),
654 'timestamp' => time(),
655 'status' => COMMENT_PUBLISHED,
656 'format' => FILTER_FORMAT_DEFAULT,
657 'thread' => $thread,
658 'name' => $account->name,
659 'mail' => '',
660 'homepage' => '',
661 'category' => $issue->category,
662 'priority' => $issue->priority,
663 'assigned' => $issue->assigned,
664 'sid' => $issue->sid,
665 'title' => $issue->title,
666 );
667
668 if (!isset($comment['project_info'])) {
669 $comment['project_info'] = array();
670 }
671 $comment['project_info'] += array(
672 'pid' => $issue->pid,
673 'rid' => $issue->rid,
674 'component' => $issue->component,
675 'assigned' => $issue->assigned,
676 );
677
678 db_query("INSERT INTO {comments} (pid, nid, uid, subject, comment, hostname, timestamp, status, format, thread, name, mail, homepage) VALUES (%d, %d, %d, '%s', '%s', '%s', %d, %d, %d, '%s', '%s', '%s', '%s')", $comment['pid'], $comment['nid'], $comment['uid'], $comment['subject'], $comment['comment'], $comment['hostname'], $comment['timestamp'], $comment['status'], $comment['format'], $comment['thread'], $comment['name'], $comment['mail'], $comment['homepage']);
679
680 $comment['cid'] = db_last_insert_id('comments', 'cid');
681
682 _comment_update_node_statistics($comment['nid']);
683
684 // Tell the other modules a new comment has been submitted.
685 comment_invoke_comment($comment, 'insert');
686 cache_clear_all();
687 return TRUE;
688 }
689 return FALSE;
690 }
691
692 /**
693 * Load and verify the followup user.
694 *
695 * @return $account
696 * The account of the followup user (or FALSE if not found).
697 */
698 function _project_issue_followup_get_user() {
699 $uid = variable_get('project_issue_followup_user', '');
700 if ($uid === '') {
701 return FALSE;
702 }
703 $account = user_load(array('uid' => $uid));
704 // Safety check -- we have to have a valid user here.
705 if (!$account) {
706 watchdog('project_issue', 'Auto-change user failed to load.', WATCHDOG_ERROR);
707 return FALSE;
708 }
709 $anon = variable_get('anonymous', t('Anonymous'));
710 $account->name = $uid ? $account->name : $anon;
711 // Safety check -- selected user must still have the correct permissions to follow up on issues.
712 if (!user_access('access project issues', $account)) {
713 watchdog('project_issue', '%name does not have sufficient permissions to follow up on issues.', array('%name' => $account->name), WATCHDOG_ERROR);
714 return FALSE;
715 }
716 return $account;
717 }
718
719 /**
720 * hook_nodeapi() implementation. This just decides what type of node
721 * is being passed, and calls the appropriate type-specific hook.
722 *
723 * @see project_issue_issue_nodeapi().
724 * @see project_issue_project_nodeapi().
725 */
726 function project_issue_nodeapi(&$node, $op, $arg) {
727 switch ($node->type) {
728 case 'project_project':
729 module_load_include('inc', 'project_issue', 'includes/project_node');
730 project_issue_project_nodeapi($node, $op, $arg);
731 break;
732 case 'project_issue':
733 project_issue_issue_nodeapi($node, $op, $arg);
734 break;
735 }
736 }
737
738 /**
739 * hook_nodeapi implementation specific to "project_issue" nodes.
740 * @see project_issue_nodeapi().
741 */
742 function project_issue_issue_nodeapi(&$node, $op, $arg) {
743 global $user;
744 switch ($op) {
745 case 'view':
746 $_GET['mode'] = COMMENT_MODE_FLAT_EXPANDED;
747 $_GET['sort'] = COMMENT_ORDER_OLDEST_FIRST;
748 project_issue_comment_view($node);
749 break;
750 case 'presave':
751 // Only for new nodes with files set.
752 if (empty($node->nid) && isset($node->files)) {
753 project_issue_rewrite_issue_filepath($node->files);
754 }
755 break;
756 case 'insert':
757 // Mark the node for email notification during hook_exit(), so all issue
758 // and file data is in a consistent state before we generate the email.
759 project_issue_set_mail_notify($node->nid);
760 break;
761 }
762 }
763
764 /**
765 * Implement hook_load() for project issue nodes.
766 */
767 function project_issue_load($node) {
768 $additions = db_fetch_array(db_query(db_rewrite_sql('SELECT pi.* FROM {project_issues} pi WHERE pi.nid = %d', 'pi'), $node->nid));
769
770 // TODO: This need to be ripped out in D6.
771 $additions['comment_form_location'] = variable_get('project_issue_comment_form_location', NULL);
772 $issue = new stdClass;
773 $issue->project_issue = $additions;
774 return $issue;
775 }
776
777 /**
778 * Implement hook_delete() for project issue nodes.
779 */
780 function project_issue_delete($node) {
781 db_query('DELETE FROM {project_issues} WHERE nid = %d', $node->nid);
782 db_query('DELETE FROM {project_issue_comments} WHERE nid = %d', $node->nid);
783 }
784
785 /**
786 * Implement hook_form() for project issue nodes.
787 */
788 function project_issue_form($node, $form_state, $include_metadata_fields = FALSE) {
789 module_load_include('inc', 'project_issue', 'includes/issue_node_form');
790 return _project_issue_form($node, $form_state, $include_metadata_fields);
791 }
792
793 /**
794 * Implement hook_validate() for project issue nodes.
795 */
796 function project_issue_validate($node) {
797 module_load_include('inc', 'project_issue', 'includes/issue_node_form');
798 return _project_issue_validate($node);
799 }
800
801 /**
802 * Implement hook_insert() for project issue nodes.
803 */
804 function project_issue_insert($node) {
805 module_load_include('inc', 'project_issue', 'includes/issue_node_form');
806 return _project_issue_insert($node);
807 }
808
809 /**
810 * Implement hook_view() for project issue nodes.
811 */
812 function project_issue_view($node, $teaser = FALSE, $page = FALSE) {
813 module_load_include('inc', 'project_issue', 'includes/issue_node_view');
814 return _project_issue_view($node, $teaser, $page);
815 }
816
817 /**
818 * Store issue nodes that need mail notifications sent.
819 *
820 * It's possible that mass inserts/updates could occur, and also possible that
821 * a given node/comment could be programatically updated more than once in a
822 * page load -- an associative array is used in order to support these cases.
823 *
824 * @param $nid
825 * The node ID of the issue node to store, or NULL to fetch the stored nids.
826 * @return
827 * If $nid is not passed, an associative array of nids that are marked for
828 * notification emails, with the following structure: key = nid, value = nid.
829 */
830 function project_issue_set_mail_notify($nid = NULL) {
831 static $nids = array();
832
833 if (!isset($nid)) {
834 $return = $nids;
835 $nids = array(); // Reset just in case this function gets called again.
836 return $return;
837 }
838 else {
839 $nids[$nid] = $nid;
840 }
841 }
842
843 /**
844 * Implementation of hook_exit().
845 */
846 function project_issue_exit() {
847 // Check for issue nodes that need mail notifications sent. This is done in
848 // hook_exit() so that all issue and file data is in a consistent state
849 // before we generate the email.
850 $nids = project_issue_set_mail_notify();
851 // For cached pages, this hook is called, but there aren't any mail functions
852 // loaded. Since the cached pages won't have any new mail notifications,
853 // we can safely test for this case.
854 if (!empty($nids)) {
855 foreach ($nids as $nid) {
856 project_mail_notify($nid);
857 }
858 }
859 }
860
861 /**
862 * Rewrites the file information to move files to the issues directory.
863 *
864 * @param $files
865 * An array of file objects, keyed by file ID.
866 */
867 function project_issue_rewrite_issue_filepath($files) {
868 if ($issue_dir = variable_get('project_directory_issues', 'issues') ) {
869 foreach ($files as $key => $file) {
870 $file = (object) $file;
871 $old_path = $file->filepath;
872 $final_dir = file_directory_path() .'/'. $issue_dir;
873 $move_path = $old_path;
874 file_move($move_path, $final_dir .'/'. basename($file->filepath));
875 $new_basename = basename($move_path);
876 db_query("UPDATE {files} SET filepath = '%s' WHERE fid = %d", $final_dir .'/'. $new_basename, $file->fid);
877 }
878 }
879 }
880
881 function project_issue_my_projects_table() {
882 $uid = 0;
883 $display = views_get_page_view();
884 if (!empty($display->view->argument['uid'])) {
885 $uid = $display->view->argument['uid']->get_value();
886 }
887
888 if (empty($uid)) {
889 return;
890 }
891
892 $header = array(
893 array('data' => t('Project'), 'field' => 'n.title', 'sort' => 'asc'),
894 array(
895 'data' => t('Last issue update'),
896 'field' => 'max_issue_changed',
897 'class' => 'project-issue-updated',
898 ),
899 array(
900 'data' => t('Open issues'),
901 'field' => 'count',
902 'class' => 'project-issues',
903 ),
904 array('data' => t('Issue links'), 'class' => 'project-issue-links'),
905 );
906 $default_states = implode(',', project_issue_default_states());
907 $result = db_query(db_rewrite_sql("SELECT n.nid, n.title, COUNT(ni.nid) AS count, MAX(ni.changed) AS max_issue_changed FROM {node} n LEFT JOIN {project_issues} pi ON n.nid = pi.pid AND pi.sid IN ($default_states) LEFT JOIN {node} ni ON ni.nid = pi.nid AND ni.status = 1 WHERE n.type = 'project_project' AND n.status = 1 AND n.uid = %d GROUP BY n.nid, n.title") . tablesort_sql($header), $uid);
908
909 $any_admin = FALSE;
910 $projects = array();
911 while ($node = db_fetch_object($result)) {
912 $node_obj = node_load($node->nid);
913 $node->is_admin = node_access('update', $node_obj);
914 $node->project['uri'] = $node_obj->project['uri'];
915 $node->project_issue['issues'] = $node_obj->project_issue['issues'];
916 $node->project_release['releases'] = isset($node_obj->project_release['releases']) ? $node_obj->project_release['releases'] : 0;
917 if ($node->is_admin) {
918 $any_admin = TRUE;
919 }
920 $projects[] = $node;
921 }
922
923 if (empty($projects)) {
924 return ($uid ? t('You have no projects.') : t('This user has no projects.'));
925 }
926
927 foreach ($projects as $node) {
928 $issue_links = array(
929 array(
930 'title' => t('View'),
931 'href' => 'project/issues/'. $node->project['uri'],
932 ),
933 array(
934 'title' => t('Search'),
935 'href' => 'project/issues/search/'. $node->project['uri'],
936 ),
937 array(
938 'title' => t('Create'),
939 'href' => 'node/add/project-issue/'. $node->project['uri'],
940 ),
941 );
942 if ($node->is_admin) {
943 $project_links = array(
944 array(
945 'title' => t('Edit'),
946 'href' => "node/$node->nid/edit",
947 ),
948 );
949 if (module_exists('project_release') && $node->project_release['releases']) {
950 $project_links[] = array(
951 'title' => t('Add release'),
952 'href' => "node/add/project-release/$node->nid",
953 );
954 }
955 }
956 if ($node->project_issue['issues']) {
957 $row = array(
958 array(
959 'data' => l($node->title, "node/$node->nid"),
960 'class' => 'project-name',
961 ),
962 array(
963 'data' => $node->max_issue_changed ? format_interval(time() - $node->max_issue_changed, 2) : t('n/a'),
964 'class' => 'project-issue-updated',
965 ),
966 array(
967 'data' => $node->count,
968 'class' => 'project-issues',
969 ),
970 array(
971 'data' => theme('links', $issue_links),
972 'class' => 'project-issue-links',
973 ),
974 );
975 }
976 else {
977 $row = array(
978 array(
979 'data' => l($node->title, "node/$node->nid"),
980 'class' => 'project-name',
981 ),
982 array(
983 'data' => t('Issue tracking is disabled.'),
984 'colspan' => $node->is_admin ? 2 : 3,
985 ),
986 );
987 if ($node->is_admin) {
988 $row[] = array(
989 'data' => l(t('Enable'), "node/$node->nid/edit/issues", array('query' => drupal_get_destination())),
990 'class' => 'project-issue-links',
991 );
992 }
993 }
994 if ($node->is_admin) {
995 $row[] = array(
996 'data' => theme('links', $project_links),
997 'class' => 'project-project-links',
998 );
999 }
1000 elseif ($any_admin) {
1001 $row[] = array();
1002 }
1003
1004 $rows[] = $row;
1005 $query->projects[] = $node->nid;
1006 }
1007
1008 if ($any_admin) {
1009 $header[] = array('data' => t('Project links'), 'class' => 'project-project-links');
1010 }
1011 return theme('table', $header, $rows, array('class' => 'projects'));
1012 }
1013
1014 /**
1015 * Page callback function for the "Issues" subtab at the site-wide search page.
1016 */
1017 function project_issue_search_page() {
1018 $view_info = variable_get('project_issue_search_issues_view', 'project_issue_search_all:default');
1019 $view_parts = explode(':', $view_info);
1020 $view = views_get_view($view_parts[0]);
1021 $view->override_path = 'search/issues';
1022 $output .= $view->preview($view_parts[1]);
1023 return $output;
1024 }
1025
1026 /**
1027 * Submit handler to adjust project issue metadata when comments are mass edited.
1028 */
1029 function project_issue_comment_mass_update($form_id, $form_values) {
1030 // This filters non-numeric values, then empty values.
1031 $cids = array_filter(array_filter($form_values['comments'], 'is_numeric'));
1032 $issue_comments = db_query("SELECT n.nid, c.cid FROM {node} n INNER JOIN {comments} c ON n.nid = c.nid WHERE c.cid IN (". implode(', ', $cids) .") AND n.type = 'project_issue'");
1033 while ($issue_comment = db_fetch_object($issue_comments)) {
1034 project_issue_update_by_comment($issue_comment, 'update');
1035 }
1036 }
1037
1038 /**
1039 * Set the breadcrumb trail for project issues and issue followups.
1040 *
1041 * Since the comment form and a full node view of an issue can appear
1042 * on both full issue pages and comment reply pages, this function checks
1043 * to see which page is being loaded, and sets the breadcrumb appropriately.
1044 *
1045 * @param $node
1046 * The issue node object.
1047 * @param $project
1048 * The project node object.
1049 */
1050 function project_issue_set_breadcrumb($node, $project) {
1051 $extra = array();
1052 $extra[] = l($project->title, 'node/'. $project->nid);
1053 $extra[] = l(t('Issues'), 'project/issues/'. $project->project['uri']);
1054 // Add the issue title if we're on a comment reply page.
1055 if (project_issue_is_comment_reply() || project_issue_is_comment_edit()) {
1056 $extra[] = l($node->title, 'node/'. $node->nid);
1057 }
1058 project_project_set_breadcrumb($project, $extra);
1059 }
1060
1061 /**
1062 * Implementation of hook_link_alter().
1063 */
1064 function project_issue_link_alter(&$links, $node) {
1065 // Only remove link for full page views.
1066 if ($node->type == 'project_issue' && arg(0) == 'node' && is_numeric(arg(1))) {
1067 unset($links['comment_add']);
1068 }
1069 }
1070
1071
1072 /**
1073 * @defgroup project_issue_filter Project Issue number to link filter.
1074 */
1075
1076 /**
1077 * Theme automatic Project Issue links.
1078 * @ingroup project_issue_filter themeable
1079 *
1080 * @param $node
1081 * The issue node object to be linked.
1082 * @param $comment_id
1083 * The comment id to be appended to the link, optional.
1084 * @param $comment_number
1085 * The comment's number, as visible to users, optional.
1086 * @param $include_assigned
1087 * Optional boolean to include the user the issue is assigned to.
1088 */
1089 function theme_project_issue_issue_link($node, $comment_id = NULL, $comment_number = NULL, $include_assigned = FALSE) {
1090 $path = "node/$node->nid";
1091
1092 // See if the issue is assigned to anyone. If so, we'll include it either
1093 // in the title attribute on hover, or next to the issue link if there was
1094 // an '@' appended to the issue nid.
1095 if (!empty($node->project_issue['assigned'])) {
1096 $username = db_result(db_query("SELECT name FROM {users} WHERE uid = %d", $node->project_issue['assigned']));
1097 }
1098 else {
1099 $username = '';
1100 }
1101
1102 if (!empty($username) && !$include_assigned) {
1103 // We have an assigned user, but we're not going to print it next to the
1104 // issue link, so include it in title. l() runs $attributes through
1105 // drupal_attributes() which escapes the value.
1106 $attributes = array('title' => t('Status: !status, Assigned to: !username', array('!status' => project_issue_state($node->project_issue['sid']), '!username' => $username)));
1107 }
1108 else {
1109 // Just the status.
1110 $attributes = array('title' => t('Status: !status', array('!status' => project_issue_state($node->project_issue['sid']))));
1111 }
1112
1113 if (isset($comment_id)) {
1114 $title = "#$node->nid-$comment_number: $node->title";
1115 $link = l($title, $path, array('attributes' => $attributes, 'fragment' => "comment-$comment_id"));
1116 }
1117 else {
1118 $title = "#$node->nid: $node->title";
1119 $link = l($title, $path, array('attributes' => $attributes));
1120 }
1121 $output = '<span class="project-issue-status-'. $node->project_issue['sid'] .' project-issue-status-info">'. $link;
1122 if ($include_assigned && !empty($username)) {
1123 $output .= ' <span class="project-issue-assigned-user">'. t('Assigned to: @username', array('@username' => $username)) .'</span>';
1124 }
1125 $output .= '</span>';
1126 return $output;
1127 }
1128
1129 /**
1130 * Implementation of hook_form_filter_tips().
1131 * @ingroup project_issue_filter
1132 */
1133 function project_issue_filter_tips($delta, $format, $long = FALSE) {
1134 if ($long) {
1135 return t("References to project issues in the form of [#1234] (or [#1234-2] for comments) turn into links automatically, with the title of the issue appended. The status of the issue is shown on hover. If '@' is appended (e.g. [#1234@]), the user the issue is assigned to will also be printed.");
1136 }
1137 else {
1138 return t('Project issue numbers (ex. [#12345]) turn into links automatically.');
1139 }
1140 }
1141
1142 /**
1143 * Implementation of hook_filter().
1144 * @ingroup project_issue_filter
1145 */
1146 function project_issue_filter($op, $delta = 0, $format = -1, $text = '') {
1147 switch ($op) {
1148 case 'list':
1149 return array(0 => t('Project Issue to link filter'));
1150 case 'description':
1151 return t('Converts references to project issues (in the form of [#12345]) into links. Caching should be disabled if node access control modules are used.');
1152 case 'no cache':
1153 return FALSE;
1154 case 'prepare':
1155 return $text;
1156 case 'process':
1157 $regex = '(?:(?<!\w)\[#\d+(?:-\d+)?(@)?\](?!\w))|<pre>.*?<\/pre>|<code>.*?<\/code>|<a(?:[^>"\']|"[^"]*"|\'[^\']*\')*>.*?<\/a>';
1158 $text = preg_replace_callback("/$regex/", 'project_issue_link_filter_callback', $text);
1159 return $text;
1160
1161 }
1162 }
1163
1164 function project_issue_link_filter_callback($matches) {
1165 $parts = array();
1166 if (preg_match('/^\[#(\d+)(?:-(\d+))?(@)?\]$/', $matches[0], $parts)) {
1167 $nid = $parts[1];
1168 $node = node_load($nid);
1169 $include_assigned = isset($parts[3]);
1170 if (is_object($node) && node_access('view', $node) && $node->type == 'project_issue') {
1171 if (isset($parts[2])) {
1172 // Pull comment id based on the comment number if we have one.
1173 $comment_number = $parts[2];
1174 if ($comment_id = db_result(db_query("SELECT pic.cid FROM {project_issue_comments} pic INNER JOIN {comments} c ON pic.cid = c.cid WHERE pic.nid = %d AND pic.comment_number = %d AND c.status = %d", $nid, $comment_number, COMMENT_PUBLISHED))) {
1175 return theme('project_issue_issue_link', $node, $comment_id, $comment_number, $include_assigned);
1176 }
1177 }
1178 // If we got this far there wasn't a valid comment number, so just link
1179 // to the node instead.
1180 return theme('project_issue_issue_link', $node, NULL, NULL, $include_assigned);
1181 }
1182