1<?php 2/** 3 * Tracker - history and notifications 4 * 5 * @link http://www.egroupware.org 6 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de> 7 * @package tracker 8 * @copyright (c) 2006-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de> 9 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 10 * @version $Id$ 11 */ 12 13use EGroupware\Api; 14use EGroupware\Api\Storage\Customfields; 15 16/** 17 * Tracker - tracking object for the tracker 18 */ 19class tracker_tracking extends Api\Storage\Tracking 20{ 21 /** 22 * Application we are tracking (required!) 23 * 24 * @var string 25 */ 26 var $app = 'tracker'; 27 /** 28 * Name of the id-field, used as id in the history log (required!) 29 * 30 * @var string 31 */ 32 var $id_field = 'tr_id'; 33 /** 34 * Name of the field with the creator id, if the creator of an entry should be notified 35 * 36 * @var string 37 */ 38 var $creator_field = 'tr_creator'; 39 /** 40 * Name of the field with the id(s) of assinged users, if they should be notified 41 * 42 * @var string 43 */ 44 var $assigned_field = 'tr_assigned'; 45 /** 46 * Translate field-name to 2-char history status 47 * 48 * @var array 49 */ 50 var $field2history = array(); 51 /** 52 * Should the user (passed to the track method or current user if not passed) be used as sender or get_config('sender') 53 * 54 * @var boolean 55 */ 56 var $prefer_user_as_sender = false; 57 /** 58 * Instance of the botracker class calling us 59 * 60 * @access private 61 * @var tracker_bo 62 */ 63 var $tracker; 64 65 /** 66 * Constructor 67 * 68 * @param tracker_bo $botracker 69 * @return tracker_tracking 70 */ 71 function __construct(tracker_bo $botracker, $notification_class=false) 72 { 73 $this->tracker = $botracker; 74 $this->field2history = $botracker->field2history; 75 76 parent::__construct('tracker', $notification_class); // adding custom fields for tracker 77 } 78 79 /** 80 * Tracks the changes in one entry $data, by comparing it with the last version in $old 81 * 82 * Overridden from parent to hide restricted comments 83 * 84 * @param array $data current entry 85 * @param array $old =null old/last state of the entry or null for a new entry 86 * @param int $user =null user who made the changes, default to current user 87 * @param boolean $deleted =null can be set to true to let the tracking know the item got deleted or undeleted 88 * @param array $changed_fields =null changed fields from ealier call to $this->changed_fields($data,$old), to not compute it again 89 * @param boolean $skip_notification =false do NOT send any notification 90 * @return int|boolean false on error, integer number of changes logged or true for new entries ($old == null) 91 */ 92 public function track(array $data,array $old=null,$user=null,$deleted=null,array $changed_fields=null,$skip_notification=false) 93 { 94 $this->user = !is_null($user) ? $user : $GLOBALS['egw_info']['user']['account_id']; 95 96 $changes = true; 97 98 // Hide restricted comments from reply count 99 foreach((array)$data['replies'] as $reply) 100 { 101 if($reply['reply_visible'] != 0) 102 { 103 $data['num_replies']--; 104 } 105 } 106 if ($old && $this->field2history) 107 { 108 // If someone made a restricted comment, hide that from change tracking (notification & history) 109 $old['num_replies'] = $data['num_replies'] - (!$data['reply_message'] || $data['reply_visible'] != 0 ? 0 : 1); 110 111 $changes = $this->save_history($data,$old,$deleted,$changed_fields); 112 } 113 // check if the not tracked field num_replies changed and count that as change to 114 // so new comments without other changes give a notification 115 if (!$changes && $old && $old['num_replies'] != $data['num_replies']) 116 { 117 $changes = true; 118 } 119 // do not run do_notifications if we have no changes, unless there was a restricted comment just made 120 if (($changes || ($data['reply_visible'] != 0)) && !$skip_notification && !$this->do_notifications($data,$old,$deleted,$changes)) 121 { 122 $changes = false; 123 } 124 return $changes; 125 } 126 127 /** 128 * Send an autoreply to the ticket creator or replier by the mailhandler 129 * 130 * @param array $data current entry 131 * @param array $autoreply values for: 132 * 'reply_text' => Texline to add to the mail message 133 * 'reply_to' => UserID or email address 134 * @param array $old =null old/last state of the entry or null for a new entry 135 */ 136 function autoreply($data,$autoreply,$old=null) 137 { 138 if (is_integer($autoreply['reply_to'])) // Mail from a known user 139 { 140 if ($this->notify_current_user) 141 { 142 return; // Already notified while saving 143 } 144 else 145 { 146 $this->notify_current_user = true; // Ensure send_notification() doesn't fail this check 147 } 148 $email = $GLOBALS['egw']->accounts->id2name($this->user,'account_email'); 149 } 150 else 151 { 152 $this->notify_current_user = true; // Ensure send_notification() doesn't fail this check 153 $email = $autoreply['reply_to']; // mail from an unknown user (set here, so we need to send a notification) 154 } 155 //error_log(__METHOD__.__LINE__.array2string($autoreply)); 156 if ($autoreply['reply_text']) 157 { 158 $data['reply_text'] = $autoreply['reply_text']; 159 $this->ClearBodyCache(); 160 } 161 // Send notification to the creator only; assignee, CC etc have been notified already 162 $this->send_notification($data,$old,$email,(is_integer($autoreply['reply_to'])?$data[$this->creator_field]:$this->get_config('lang',$data))); 163 } 164 165 /** 166 * Send notifications for changed entry 167 * 168 * Overridden to hide restricted comments. Sends restricted first to all but creator, then unrestricted to creator 169 * 170 * @internal use only track($data,$old,$user) 171 * @param array $data current entry 172 * @param array $old =null old/last state of the entry or null for a new entry 173 * @param boolean $deleted =null can be set to true to let the tracking know the item got deleted or undelted 174 * @return boolean true on success, false on error (error messages are in $this->errors) 175 */ 176 public function do_notifications($data,$old,$deleted, $changes) 177 { 178 $skip = $this->get_config('skip_notify',$data,$old); 179 $email_notified = $skip ? $skip : array(); 180 181 // Send all to others 182 $creator = $data[$this->creator_field]; 183 $creator_field = $this->creator_field; 184 if(!($this->tracker->is_admin($data['tr_tracker'], $creator, true) || $this->tracker->is_technician($data['tr_tracker'], $creator))) 185 { 186 // Notify the creator with full info if they're an admin or technician 187 $this->creator_field = null; 188 } 189 190 // Don't send CC 191 $private = $data['tr_private']; 192 $data['tr_private'] = true; 193 194 // Send notification - $email_notified will be skipped 195 $success = parent::do_notifications($data, $old, $deleted, $email_notified); 196 197 //error_log(__METHOD__.__LINE__." email notified with restricted comments:".array2string($email_notified)); 198 199 if(!$changes) 200 { 201 // Only thing that really changed was a restricted comment 202 //error_log(__METHOD__.':'.__LINE__.' Stopping, no other changes'); 203 return $success; 204 } 205 // clears the cached notifications body 206 $this->ClearBodyCache(); 207 208 // Edit messages 209 foreach((array)$data['replies'] as $key => $reply) 210 { 211 if($reply['reply_visible'] != 0) 212 { 213 unset($data['replies'][$key]); 214 } 215 } 216 217 // Send to creator (if not already notified) && CC 218 if(!($this->tracker->is_admin($data['tr_tracker'], $creator, true) || $this->tracker->is_technician($data['tr_tracker'], $creator))) 219 { 220 $this->creator_field = $creator_field; 221 } 222 $this->tracker->preset_replies[$data['tr_id']] = $data['replies']; 223 $data['tr_private'] = $private; 224 //$already_notified = $email_notified; 225 $ret = $success && parent::do_notifications($data, $old, $deleted, $email_notified); 226 //error_log(__METHOD__.__LINE__." email notified, restricted comments removed:".array2string(array_diff($email_notified,$already_notified))); 227 228 return $ret; 229 } 230 231 /** 232 * Get a notification-config value 233 * 234 * @param string $name 235 * - 'copy' array of email addresses notifications should be copied too, can depend on $data 236 * - 'lang' string lang code for copy mail 237 * - 'sender' string send email address 238 * @param array $data current entry 239 * @param array $old =null old/last state of the entry or null for a new entry 240 * @return mixed 241 */ 242 function get_config($name,$data,$old=null) 243 { 244 unset($old); // not used 245 246 $tracker = $data['tr_tracker']; 247 248 $config = $this->tracker->notification[$tracker][$name] ? $this->tracker->notification[$tracker][$name] : $this->tracker->notification[0][$name]; 249 250 switch($name) 251 { 252 case 'copy': // include the tr_cc addresses 253 // If not set for this queue or all queues, default to true 254 $no_external = $this->tracker->notification[$tracker]['no_external'] ? 255 $this->tracker->notification[$tracker]['no_external'] : 256 $this->tracker->notification[0]['no_external']; 257 258 if ($data['tr_private'] || $no_external) 259 { 260 return array(); // no copies for private entries 261 } 262 $config = $config ? preg_split('/, ?/',$config) : array(); 263 if ($data['tr_cc']) 264 { 265 $config = array_merge($config,preg_split('/, ?/',$data['tr_cc'])); 266 } 267 break; 268 case 'skip_notify': 269 $config = array_merge((array)$config,$data['skip_notify'] ? $data['skip_notify'] : (array)$this->skip_notify); 270 break; 271 case 'reply_to': 272 if (empty($config)) // if no explicit reply_to set in notifications use sender from mail config 273 { 274 $config = $this->tracker->notification[$tracker]['sender'] ? 275 $this->tracker->notification[$tracker]['sender'] : 276 $this->tracker->notification[0]['sender']; 277 } 278 break; 279 } 280 //error_log(__METHOD__.__LINE__.' Name:'.$name.' -> '.array2string($config).' Data:'.array2string($data)); 281 return $config; 282 } 283 284 /** 285 * Get the subject for a given entry, reimplementation for get_subject in Api\Storage\Tracking 286 * 287 * Default implementation uses the link-title 288 * 289 * @param array $data 290 * @param array $old 291 * @return string 292 */ 293 function get_subject($data,$old) 294 { 295 unset($old); // not used 296 297 return $data['prefix'] . $this->tracker->trackers[$data['tr_tracker']].' #'.$data['tr_id'].': '.$data['tr_summary']; 298 } 299 300 /** 301 * Get the body of the notification message 302 * If there is a custom notification message configured, that will be used. Otherwise, the 303 * default message will be used. 304 * 305 * @param boolean $html_email 306 * @param array $data 307 * @param array $old 308 * @param boolean $integrate_link to have links embedded inside the body 309 * @param int|string $receiver numeric account_id or email address 310 * @return string 311 */ 312 function get_body($html_email,$data,$old,$integrate_link = true,$receiver=null) 313 { 314 $notification = $this->tracker->notification[$data['tr_tracker']]; 315 $merge = new tracker_merge(); 316 317 // Set comments according to data, avoids re-reading from DB 318 if (isset($data['replies'])) $merge->set_comments($data['tr_id'], $data['replies']); 319 320 if(trim(strip_tags($notification['message'])) == '' || !$notification['use_custom']) 321 { 322 $notification['message'] = $this->tracker->notification[0]['message']; 323 } 324 if(trim(strip_tags($notification['signature'])) == '' || !$notification['use_signature']) 325 { 326 $notification['signature'] = $this->tracker->notification[0]['signature']; 327 } 328 if(!$notification['use_signature'] && !$this->tracker->notification[0]['use_signature']) $notification['signature'] = ''; 329 330 // If no signature set, use the global one 331 if(!$notification['signature']) 332 { 333 $notification['signature'] = parent::get_signature($data,$old,$receiver); 334 } 335 else 336 { 337 $error = null; 338 $notification['signature'] = $merge->merge_string($notification['signature'], array($data['tr_id']), $error, 'text/html'); 339 } 340 341 if((!$notification['use_custom'] && !$this->tracker->notification[0]['use_custom']) || !$notification['message']) 342 { 343 // Always use text mode for text tickets, HTML for HTML tickets 344 $html = $this->html_content_allow; 345 $this->html_content_allow = $data['tr_edit_mode'] !== 'ascii'; 346 347 $body = parent::get_body($html_email,$data,$old,$integrate_link,$receiver).($html_email?"<br />\n":"\n"). 348 $notification['signature']; 349 350 $this->html_content_allow = $html; 351 return $body; 352 } 353 354 $message = $this->sanitize_custom_message($notification['message'], $receiver); 355 $message = $merge->merge_string($message, array($data['tr_id']), $error, 'text/html'); 356 if(strpos($notification['message'], '{{signature}}') === False) 357 { 358 $message.=($html_email?"<br />\n":"\n"). 359 $notification['signature']; 360 } 361 if($error) 362 { 363 error_log($error); 364 return parent::get_body($html_email,$data,$old,$integrate_link,$receiver)."\n".$notification['signature']; 365 } 366 return $html_email ? $message : Api\Mail\Html::convertHTMLToText(Api\Html::purify($message), false, true, true); 367 } 368 369 /** 370 * Override parent to return nothing, it's taken care of in get_body() 371 * 372 * @see get_body() 373 */ 374 protected function get_signature($data,$old,$receiver) 375 { 376 unset($data,$old,$receiver); // not used 377 378 return false; 379 } 380 381 /** 382 * Get the modified / new message (1. line of mail body) for a given entry, can be reimplemented 383 * 384 * @param array $data 385 * @param array $old 386 * @return array (of strings) for multiline messages 387 */ 388 function get_message($data,$old) 389 { 390 if($data['message']) return $data['message']; 391 392 if ($data['reply_text']) 393 { 394 $r[] = $data['reply_text']; 395 $r[] = '---';// this is wanted for separation of reply_text to status/creation text 396 } 397 398 if (!$data['tr_modified'] || !$old) 399 { 400 $r[] = lang('New ticket submitted by %1 at %2', 401 Api\Accounts::username($data['tr_creator']), 402 $this->datetime($data['tr_created_servertime'])); 403 return $r; 404 } 405 $r[] = lang('Ticket modified by %1 at %2', 406 $data['tr_modifier'] ? Api\Accounts::username($data['tr_modifier']) : lang('Tracker'), 407 $this->datetime($data['tr_modified_servertime'])); 408 return $r; 409 } 410 411 /** 412 * Get the details of an entry 413 * 414 * @param array $data 415 * @param int|string $receiver numeric account_id or email address 416 * @return array of details as array with values for keys 'label','value','type' 417 */ 418 function get_details($data, $receiver) 419 { 420 static $cats=null,$versions=null,$statis=null,$priorities=null,$resolutions=null; 421 if (!$cats) 422 { 423 $cats = $this->tracker->get_tracker_labels('cat',$data['tr_tracker']); 424 $versions = $this->tracker->get_tracker_labels('version',$data['tr_tracker']); 425 $statis = $this->tracker->get_tracker_stati($data['tr_tracker']); 426 $priorities = $this->tracker->get_tracker_priorities($data['tr_tracker']); 427 $resolutions = $this->tracker->get_tracker_labels('resolution',$data['tr_tracker']); 428 } 429 if ($data['tr_assigned']) 430 { 431 foreach($data['tr_assigned'] as $uid) 432 { 433 $assigned[] = Api\Accounts::username($uid); 434 } 435 $assigned = implode(', ',$assigned); 436 } 437/* 438 if ($data['reply_text']) 439 { 440 $details['reply_text'] = array( 441 'value' => $data['reply_text'], 442 'type' => 'message', 443 ); 444 } 445*/ 446 $detail_fields = array( 447 'tr_tracker' => $this->tracker->trackers[$data['tr_tracker']], 448 'cat_id' => $cats[$data['cat_id']], 449 'tr_version' => $versions[$data['tr_version']], 450 'tr_startdate' => $this->datetime($data['tr_startdate']), 451 'tr_duedate' => $this->datetime($data['tr_duedate']), 452 'tr_status' => lang($statis[$data['tr_status']]), 453 'tr_resolution' => lang($resolutions[$data['tr_resolution']]), 454 'tr_completion' => (int)$data['tr_completion'].'%', 455 'tr_priority' => lang($priorities[$data['tr_priority']]), 456 'tr_creator' => Api\Accounts::username($data['tr_creator']), 457 'tr_created' => $this->datetime($data['tr_created']), 458 'tr_assigned' => !$data['tr_assigned'] ? lang('Not assigned') : $assigned, 459 'tr_cc' => $data['tr_cc'], 460 // The layout of tr_summary should NOT be changed in order for 461 // tracker.tracker_mailhandler.get_ticketId() to work! 462 'tr_summary' => '#'.$data['tr_id'].' - '.$data['tr_summary'], 463 ); 464 465 // Don't show start date / due date if disabled or not set 466 $config = Api\Config::read('tracker'); 467 if(!$config['show_dates']) 468 { 469 unset($detail_fields['tr_startdate']); 470 unset($detail_fields['tr_duedate']); 471 } 472 if(!$data['tr_startdate']) unset($detail_fields['tr_startdate']); 473 if(!$data['tr_duedate']) unset($detail_fields['tr_duedate']); 474 475 foreach($detail_fields as $name => $value) 476 { 477 $details[$name] = array( 478 'label' => lang($this->tracker->field2label[$name]), 479 'value' => $value, 480 ); 481 if ($name == 'tr_summary') $details[$name]['type'] = 'summary'; 482 } 483 // add custom fields for given type 484 $details += $this->get_customfields($data, $data['tr_tracker'], $receiver); 485 486 if ($data['replies']) //$data['reply_message'] && !$data['reply_visible']) 487 { 488 // At least one comment was made 489 $reply = $data['replies'][0]; 490 $details[] = array( 491 'type' => 'message', 492 'label' => lang('Comment by %1 at %2:',$reply['reply_creator'] ? Api\Accounts::username($reply['reply_creator']) : lang('Tracker'),$this->datetime($reply['reply_servertime'])), 493 'value' => ' ' 494 ); 495 $details[] = array( 496 'type' => 'reply', 497 'value' => $data['tr_edit_mode'] == 'ascii' ? 498 preg_replace("@\n\n+@", "\n", $reply['reply_message']) : 499 preg_replace("@\n\n+|<br ?/?>\n?<br ?/?>@", "<br>", $reply['reply_message']) 500 ); 501 $n = 2; 502 } 503 $details[] = array( 504 'value' => lang('Description'), 505 'type' => 'summary' 506 ); 507 $details['tr_description'] = array( 508 'value' => $data['tr_edit_mode'] == 'ascii' ? htmlspecialchars_decode($data['tr_description']) : $data['tr_description'], 509 'type' => 'multiline', 510 ); 511 if ($data['replies']) 512 { 513 foreach($data['replies'] as $reply_index => $reply) 514 { 515 if(!$reply['reply_message']) continue; 516 $reply['reply_message'] = $data['tr_edit_mode'] == 'ascii' ? 517 preg_replace("@\n\n+@", "\n", $reply['reply_message']) : 518 preg_replace("@\n\n+|<br ?/?>\n?<br ?/?>@", "<br>", $reply['reply_message']); 519 $msg = array( // first reply need to be checked against old to marked modified for new 520 'value' => lang('Comment by %1 at %2:',$reply['reply_creator'] ? Api\Accounts::username($reply['reply_creator']) : lang('Tracker'), 521 $this->datetime($reply['reply_servertime'])), 522 'type' => 'reply', 523 ); 524 if(!$reply_index) 525 { 526 $details['replies'] = $msg; 527 } 528 else 529 { 530 $details[] = $msg; 531 } 532 $details[] = array( 533 'value' => $reply['reply_message'], 534 'type' => 'multiline', 535 ); 536 } 537 } 538 return $details; 539 } 540 541 /** 542 * Override to extend permission so tracker_merge can use it 543 */ 544 public function get_link($data,$old,$allow_popup=false,$receiver=null) 545 { 546 return parent::get_link($data,$old,$allow_popup,$receiver); 547 } 548 549 /** 550 * Compute changes between new and old data 551 * 552 * Reimplemented to cope with some tracker specialties: 553 * - tr_completion is postfixed with a percent 554 * 555 * @param array $data 556 * @param array $old =null 557 * @return array of keys with different values in $data and $old 558 */ 559 public function changed_fields(array $data,array $old=null) 560 { 561 $changed = parent::changed_fields($data, $old); 562 563 // for tr_completion ignore percent postfix 564 if (($k = array_search('tr_completion', $changed)) !== false && 565 (int)$data['tr_completion'] === (int)$old['tr_completion']) 566 { 567 unset($changed[$k]); 568 } 569 return $changed; 570 } 571} 572