1<?php 2/********************************************************************* 3 class.queue.php 4 5 Custom (ticket) queues for osTicket 6 7 Jared Hancock <jared@osticket.com> 8 Peter Rotich <peter@osticket.com> 9 Copyright (c) 2006-2015 osTicket 10 http://www.osticket.com 11 12 Released under the GNU General Public License WITHOUT ANY WARRANTY. 13 See LICENSE.TXT for details. 14 15 vim: expandtab sw=4 ts=4 sts=4: 16**********************************************************************/ 17 18class CustomQueue extends VerySimpleModel { 19 static $meta = array( 20 'table' => QUEUE_TABLE, 21 'pk' => array('id'), 22 'ordering' => array('sort'), 23 'select_related' => array('parent', 'default_sort'), 24 'joins' => array( 25 'children' => array( 26 'reverse' => 'CustomQueue.parent', 27 'constrain' => ['children__id__gt' => 0], 28 ), 29 'columns' => array( 30 'reverse' => 'QueueColumnGlue.queue', 31 'constrain' => array('staff_id' =>'QueueColumnGlue.staff_id'), 32 'broker' => 'QueueColumnListBroker', 33 ), 34 'sorts' => array( 35 'reverse' => 'QueueSortGlue.queue', 36 'broker' => 'QueueSortListBroker', 37 ), 38 'default_sort' => array( 39 'constraint' => array('sort_id' => 'QueueSort.id'), 40 'null' => true, 41 ), 42 'exports' => array( 43 'reverse' => 'QueueExport.queue', 44 ), 45 'parent' => array( 46 'constraint' => array( 47 'parent_id' => 'CustomQueue.id', 48 ), 49 'null' => true, 50 ), 51 'staff' => array( 52 'constraint' => array( 53 'staff_id' => 'Staff.staff_id', 54 ) 55 ), 56 ) 57 ); 58 59 const FLAG_PUBLIC = 0x0001; // Shows up in e'eryone's saved searches 60 const FLAG_QUEUE = 0x0002; // Shows up in queue navigation 61 const FLAG_DISABLED = 0x0004; // NOT enabled 62 const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent 63 const FLAG_INHERIT_COLUMNS = 0x0010; // Inherit column layout from parent 64 const FLAG_INHERIT_SORTING = 0x0020; // Inherit advanced sorting from parent 65 const FLAG_INHERIT_DEF_SORT = 0x0040; // Inherit default selected sort 66 const FLAG_INHERIT_EXPORT = 0x0080; // Inherit export fields from parent 67 68 69 const FLAG_INHERIT_EVERYTHING = 0x158; // Maskf or all INHERIT flags 70 71 var $criteria; 72 var $_conditions; 73 74 static function queues() { 75 return parent::objects()->filter(array( 76 'flags__hasbit' => static::FLAG_QUEUE 77 )); 78 } 79 80 function __onload() { 81 // Ensure valid state 82 if ($this->hasFlag(self::FLAG_INHERIT_COLUMNS) && !$this->parent_id) 83 $this->clearFlag(self::FLAG_INHERIT_COLUMNS); 84 85 if ($this->hasFlag(self::FLAG_INHERIT_EXPORT) && !$this->parent_id) 86 $this->clearFlag(self::FLAG_INHERIT_EXPORT); 87 } 88 89 function getId() { 90 return $this->id; 91 } 92 93 function getName() { 94 return $this->title; 95 } 96 97 function getHref() { 98 // TODO: Get base page from getRoot(); 99 $root = $this->getRoot(); 100 return 'tickets.php?queue='.$this->getId(); 101 } 102 103 function getRoot() { 104 switch ($this->root) { 105 case 'T': 106 default: 107 return 'Ticket'; 108 } 109 } 110 111 function getPath() { 112 return $this->path ?: $this->buildPath(); 113 } 114 115 function criteriaRequired() { 116 return true; 117 } 118 119 function getCriteria($include_parent=false) { 120 if (!isset($this->criteria)) { 121 $this->criteria = is_string($this->config) 122 ? JsonDataParser::decode($this->config) 123 : $this->config; 124 // XXX: Drop this block in v1.12 125 // Auto-upgrade v1.10 saved-search criteria to new format 126 // But support new style with `conditions` support 127 $old = @$this->config[0] === '{'; 128 if ($old && is_array($this->criteria) 129 && !isset($this->criteria['conditions']) 130 ) { 131 // TODO: Upgrade old ORM path names 132 // Parse criteria out of JSON if any. 133 $this->criteria = self::isolateCriteria($this->criteria, 134 $this->getRoot()); 135 } 136 } 137 $criteria = $this->criteria ?: array(); 138 // Support new style with `conditions` support 139 if (isset($criteria['criteria'])) 140 $criteria = $criteria['criteria']; 141 if ($include_parent && $this->parent_id && $this->parent) { 142 $criteria = array_merge($this->parent->getCriteria(true), 143 $criteria); 144 } 145 return $criteria; 146 } 147 148 function describeCriteria($criteria=false){ 149 global $account; 150 151 if (!($all = $this->getSupportedMatches($this->getRoot()))) 152 return ''; 153 154 $items = array(); 155 $criteria = $criteria ?: $this->getCriteria(true); 156 foreach ($criteria ?: array() as $C) { 157 list($path, $method, $value) = $C; 158 if ($path === ':keywords') { 159 $items[] = Format::htmlchars("\"{$value}\""); 160 continue; 161 } 162 if (!isset($all[$path])) 163 continue; 164 list($label, $field) = $all[$path]; 165 $items[] = $field->describeSearch($method, $value, $label); 166 } 167 return implode("\nAND ", $items); 168 } 169 170 /** 171 * Fetch an AdvancedSearchForm instance for use in displaying or 172 * configuring this search in the user interface. 173 * 174 * Parameters: 175 * $search - <array> Request parameters ($_POST) used to update the 176 * search beyond the current configuration of the search criteria 177 * $searchables - search fields - default to current if not provided 178 */ 179 function getForm($source=null, $searchable=null) { 180 $fields = array(); 181 if (!isset($searchable)) { 182 $fields = array( 183 ':keywords' => new TextboxField(array( 184 'id' => 3001, 185 'configuration' => array( 186 'size' => 40, 187 'length' => 400, 188 'autofocus' => true, 189 'classes' => 'full-width headline', 190 'placeholder' => __('Keywords — Optional'), 191 ), 192 'validators' => function($self, $v) { 193 if (mb_str_wc($v) > 3) 194 $self->addError(__('Search term cannot have more than 3 keywords')); 195 }, 196 )), 197 ); 198 199 $searchable = $this->getCurrentSearchFields($source); 200 } 201 202 foreach ($searchable ?: array() as $path => $field) 203 $fields = array_merge($fields, static::getSearchField($field, $path)); 204 205 $form = new AdvancedSearchForm($fields, $source); 206 207 // Field selection validator 208 if ($this->criteriaRequired()) { 209 $form->addValidator(function($form) { 210 if (!$form->getNumFieldsSelected()) 211 $form->addError(__('No fields selected for searching')); 212 }); 213 } 214 215 // Load state from current configuraiton 216 if (!$source) { 217 foreach ($this->getCriteria() as $I) { 218 list($path, $method, $value) = $I; 219 if ($path == ':keywords' && $method === null) { 220 if ($F = $form->getField($path)) 221 $F->value = $value; 222 continue; 223 } 224 225 if (!($F = $form->getField("{$path}+search"))) 226 continue; 227 $F->value = true; 228 229 if (!($F = $form->getField("{$path}+method"))) 230 continue; 231 $F->value = $method; 232 233 if ($value && ($F = $form->getField("{$path}+{$method}"))) 234 $F->value = $value; 235 } 236 } 237 return $form; 238 } 239 240 /** 241 * Fetch a bucket of fields for a custom search. The fields should be 242 * added to a form before display. One searchable field may encompass 10 243 * or more actual fields because fields are expanded to support multiple 244 * search methods along with the fields for each search method. This 245 * method returns all the FormField instances for all the searchable 246 * model fields currently in use. 247 * 248 * Parameters: 249 * $source - <array> data from a request. $source['fields'] is expected 250 * to contain a list extra fields by ORM path, of newly added 251 * fields not yet saved in this object's getCriteria(). 252 */ 253 function getCurrentSearchFields($source=array(), $criteria=array()) { 254 static $basic = array( 255 'Ticket' => array( 256 'status__id', 257 'status__state', 258 'dept_id', 259 'assignee', 260 'topic_id', 261 'created', 262 'est_duedate', 263 'duedate', 264 ) 265 ); 266 267 $all = $this->getSupportedMatches(); 268 $core = array(); 269 270 // Include basic fields for new searches 271 if (!isset($this->id)) 272 foreach ($basic[$this->getRoot()] as $path) 273 if (isset($all[$path])) 274 $core[$path] = $all[$path]; 275 276 // Add others from current configuration 277 foreach ($criteria ?: $this->getCriteria() as $C) { 278 list($path) = $C; 279 if (isset($all[$path])) 280 $core[$path] = $all[$path]; 281 } 282 283 if (isset($source['fields'])) 284 foreach ($source['fields'] as $path) 285 if (isset($all[$path])) 286 $core[$path] = $all[$path]; 287 288 return $core; 289 } 290 291 /** 292 * Fetch all supported ORM fields filterable by this search object. 293 */ 294 function getSupportedFilters() { 295 return static::getFilterableFields($this->getRoot()); 296 } 297 298 299 /** 300 * Get get supplemental matches for public queues. 301 * 302 */ 303 304 function getSupplementalMatches() { 305 return array(); 306 } 307 308 function getSupplementalCriteria() { 309 return array(); 310 } 311 312 /** 313 * Fetch all supported ORM fields searchable by this search object. The 314 * returned list represents searchable fields, keyed by the ORM path. 315 * Use ::getCurrentSearchFields() or ::getSearchField() to retrieve for 316 * use in the user interface. 317 */ 318 function getSupportedMatches() { 319 return static::getSearchableFields($this->getRoot()); 320 } 321 322 /** 323 * Trace ORM fields from a base object and retrieve a complete list of 324 * fields which can be used in an ORM query based on the base object. 325 * The base object must implement Searchable interface and extend from 326 * VerySimpleModel. Then all joins from the object are also inspected, 327 * and any which implement the Searchable interface are traversed and 328 * automatically added to the list. The resulting list is cached based 329 * on the $base class, so multiple calls for the same $base return 330 * quickly. 331 * 332 * Parameters: 333 * $base - Class, name of a class implementing Searchable 334 * $recurse - int, number of levels to recurse, default is 2 335 * $cache - bool, cache results for future class for the same base 336 * $customData - bool, include all custom data fields for all general 337 * forms 338 */ 339 static function getSearchableFields($base, $recurse=2, 340 $customData=true, $exclude=array() 341 ) { 342 static $cache = array(), $otherFields; 343 344 // Early exit if already cached 345 $fields = &$cache[$base]; 346 if ($fields) 347 return $fields; 348 349 if (!in_array('Searchable', class_implements($base))) 350 return array(); 351 352 $fields = $fields ?: array(); 353 foreach ($base::getSearchableFields() as $path=>$F) { 354 if (is_array($F)) { 355 list($label, $field) = $F; 356 } 357 else { 358 $label = $F->getLocal('label'); 359 $field = $F; 360 } 361 $fields[$path] = array($label, $field); 362 } 363 364 if ($customData && $base::supportsCustomData()) { 365 if (!isset($otherFields)) { 366 $otherFields = array(); 367 $dfs = DynamicFormField::objects() 368 ->filter(array('form__type' => 'G')) 369 ->select_related('form'); 370 foreach ($dfs as $field) { 371 $otherFields[$field->getId()] = array($field->form, 372 $field->getImpl()); 373 } 374 } 375 foreach ($otherFields as $id=>$F) { 376 list($form, $field) = $F; 377 $label = sprintf("%s / %s", 378 $form->getTitle(), $field->getLocal('label')); 379 $fields["entries__answers!{$id}__value"] = array( 380 $label, $field); 381 } 382 } 383 384 if ($recurse) { 385 $exclude[$base] = 1; 386 foreach ($base::getMeta('joins') as $path=>$j) { 387 $fc = $j['fkey'][0]; 388 if (isset($exclude[$fc]) || $j['list'] 389 || (isset($j['searchable']) && !$j['searchable'])) 390 continue; 391 foreach (static::getSearchableFields($fc, $recurse-1, 392 true, $exclude) 393 as $path2=>$F) { 394 list($label, $field) = $F; 395 $fields["{$path}__{$path2}"] = array( 396 sprintf("%s / %s", $fc, $label), 397 $field); 398 } 399 } 400 } 401 402 // Sort the field listing by the (localized) label name 403 if (function_exists('collator_create')) { 404 $coll = Collator::create(Internationalization::getCurrentLanguage()); 405 $keys = array_map(function($a) use ($coll) { 406 return $coll->getSortKey($a[0]); #nolint 407 }, $fields); 408 } 409 else { 410 // Fall back to 8-bit string sorting 411 $keys = array_map(function($a) { return $a[0]; }, $fields); 412 } 413 array_multisort($keys, $fields); 414 415 return $fields; 416 } 417 418 /** 419 * Fetch all searchable fileds, for the base object which support quick filters. 420 */ 421 function getFilterableFields($object) { 422 $filters = array(); 423 foreach (static::getSearchableFields($object) as $p => $f) { 424 list($label, $field) = $f; 425 if ($field && $field->supportsQuickFilter()) 426 $filters[$p] = $f; 427 } 428 429 return $filters; 430 } 431 432 /** 433 * Fetch the FormField instances used when for configuring a searchable 434 * field in the user interface. This is the glue between a field 435 * representing a searchable model field and the configuration of that 436 * search in the user interface. 437 * 438 * Parameters: 439 * $F - <array<string, FormField>> the label and the FormField instance 440 * representing the configurable search 441 * $name - <string> ORM path for the search 442 */ 443 static function getSearchField($F, $name) { 444 list($label, $field) = $F; 445 446 $pieces = array(); 447 $pieces["{$name}+search"] = new BooleanField(array( 448 'id' => sprintf('%u', crc32($name)) >> 1, 449 'configuration' => array( 450 'desc' => $label ?: $field->getLocal('label'), 451 'classes' => 'inline', 452 ), 453 )); 454 $methods = $field->getSearchMethods(); 455 456 //remove future options for datetime fields that can't be in the future 457 if (in_array($field->getLabel(), DateTimeField::getPastPresentLabels())) 458 unset($methods['ndays'], $methods['future'], $methods['distfut']); 459 460 $pieces["{$name}+method"] = new ChoiceField(array( 461 'choices' => $methods, 462 'default' => key($methods), 463 'visibility' => new VisibilityConstraint(new Q(array( 464 "{$name}+search__eq" => true, 465 )), VisibilityConstraint::HIDDEN), 466 )); 467 $offs = 0; 468 foreach ($field->getSearchMethodWidgets() as $m=>$w) { 469 if (!$w) 470 continue; 471 list($class, $args) = $w; 472 $args['required'] = true; 473 $args['__searchval__'] = true; 474 $args['visibility'] = new VisibilityConstraint(new Q(array( 475 "{$name}+method__eq" => $m, 476 )), VisibilityConstraint::HIDDEN); 477 $pieces["{$name}+{$m}"] = new $class($args); 478 } 479 return $pieces; 480 } 481 482 function getField($path) { 483 $searchable = $this->getSupportedMatches(); 484 return $searchable[$path]; 485 } 486 487 // Remove this and adjust advanced-search-criteria template to use the 488 // getCriteria() list and getField() 489 function getSearchFields($form=false) { 490 $form = $form ?: $this->getForm(); 491 $searchable = $this->getCurrentSearchFields(); 492 $info = array(); 493 foreach ($form->getFields() as $f) { 494 if (substr($f->get('name'), -7) == '+search') { 495 $name = substr($f->get('name'), 0, -7); 496 $value = null; 497 // Determine the search method and fetch the original field 498 if (($M = $form->getField("{$name}+method")) 499 && ($method = $M->getClean()) 500 && (list(,$field) = $searchable[$name]) 501 ) { 502 // Request the field to generate a search Q for the 503 // search method and given value 504 if ($value = $form->getField("{$name}+{$method}")) 505 $value = $value->getClean(); 506 } 507 $info[$name] = array( 508 'field' => $field, 509 'method' => $method, 510 'value' => $value, 511 'active' => $f->getClean(), 512 ); 513 } 514 } 515 return $info; 516 } 517 518 /** 519 * Take the criteria from the SavedSearch fields setup and isolate the 520 * field name being search, the method used for searhing, and the method- 521 * specific data entered in the UI. 522 */ 523 static function isolateCriteria($criteria, $base='Ticket') { 524 525 if (!is_array($criteria)) 526 return null; 527 528 $items = array(); 529 $searchable = static::getSearchableFields($base); 530 foreach ($criteria as $k=>$v) { 531 if (substr($k, -7) === '+method') { 532 list($name,) = explode('+', $k, 2); 533 if (!isset($searchable[$name])) 534 continue; 535 536 // Require checkbox to be checked too 537 if (!$criteria["{$name}+search"]) 538 continue; 539 540 // Lookup the field to search this condition 541 list($label, $field) = $searchable[$name]; 542 // Get the search method 543 $method = is_array($v) ? key($v) : $v; 544 // Not all search methods require a value 545 $value = $criteria["{$name}+{$method}"]; 546 547 $items[] = array($name, $method, $value); 548 } 549 } 550 if (isset($criteria[':keywords']) 551 && ($kw = $criteria[':keywords']) 552 ) { 553 $items[] = array(':keywords', null, $kw); 554 } 555 return $items; 556 } 557 558 function getConditions() { 559 if (!isset($this->_conditions)) { 560 $this->getCriteria(); 561 $conds = array(); 562 if (is_array($this->criteria) 563 && isset($this->criteria['conditions']) 564 ) { 565 $conds = $this->criteria['conditions']; 566 } 567 foreach ($conds as $C) 568 if ($T = QueueColumnCondition::fromJson($C)) 569 $this->_conditions[] = $T; 570 } 571 return $this->_conditions; 572 } 573 574 function getExportableFields() { 575 $cdata = $fields = array(); 576 foreach (TicketForm::getInstance()->getFields() as $f) { 577 // Ignore core fields 578 if (in_array($f->get('name'), array('priority'))) 579 continue; 580 // Ignore non-data fields 581 elseif (!$f->hasData() || $f->isPresentationOnly()) 582 continue; 583 // Ignore disabled fields 584 elseif (!$f->hasFlag(DynamicFormField::FLAG_ENABLED)) 585 continue; 586 587 $name = $f->get('name') ?: 'field_'.$f->get('id'); 588 $key = 'cdata__'.$name; 589 $cdata[$key] = $f->getLocal('label'); 590 } 591 592 // Standard export fields if none is provided. 593 $fields = array( 594 'number' => __('Ticket Number'), 595 'created' => __('Date Created'), 596 'cdata__subject' => __('Subject'), 597 'user__name' => __('From'), 598 'user__emails__address' => __('From Email'), 599 'cdata__priority' => __('Priority'), 600 'dept_id' => __('Department'), 601 'topic_id' => __('Help Topic'), 602 'source' => __('Source'), 603 'status__id' =>__('Current Status'), 604 'lastupdate' => __('Last Updated'), 605 'est_duedate' => __('SLA Due Date'), 606 'sla_id' => __('SLA Plan'), 607 'duedate' => __('Due Date'), 608 'closed' => __('Closed Date'), 609 'isoverdue' => __('Overdue'), 610 'merged' => __('Merged'), 611 'linked' => __('Linked'), 612 'isanswered' => __('Answered'), 613 'staff_id' => __('Agent Assigned'), 614 'team_id' => __('Team Assigned'), 615 'thread_count' => __('Thread Count'), 616 'reopen_count' => __('Reopen Count'), 617 'attachment_count' => __('Attachment Count'), 618 'task_count' => __('Task Count'), 619 ) + $cdata; 620 621 return $fields; 622 } 623 624 function getExportFields($inherit=true) { 625 626 $fields = array(); 627 if ($inherit 628 && $this->parent_id 629 && $this->hasFlag(self::FLAG_INHERIT_EXPORT) 630 && $this->parent 631 ) { 632 $fields = $this->parent->getExportFields(); 633 } 634 elseif (count($this->exports)) { 635 foreach ($this->exports as $f) 636 $fields[$f->path] = $f->getHeading(); 637 } 638 elseif ($this->isAQueue()) 639 $fields = $this->getExportableFields(); 640 641 if (!count($fields)) 642 $fields = $this->getExportableFields(); 643 644 return $fields; 645 } 646 647 function getExportColumns($fields=array()) { 648 $columns = array(); 649 $fields = $fields ?: $this->getExportFields(); 650 $i = 0; 651 foreach ($fields as $path => $label) { 652 $c = QueueColumn::placeholder(array( 653 'id' => $i++, 654 'heading' => $label, 655 'primary' => $path, 656 )); 657 $c->setQueue($this); 658 $columns[$path] = $c; 659 } 660 return $columns; 661 } 662 663 function getStandardColumns() { 664 return $this->getColumns(); 665 } 666 667 function getColumns($use_template=false) { 668 if ($this->columns_id 669 && ($q = CustomQueue::lookup($this->columns_id)) 670 ) { 671 // Use columns from cited queue 672 return $q->getColumns(); 673 } 674 elseif ($this->parent_id 675 && $this->hasFlag(self::FLAG_INHERIT_COLUMNS) 676 && $this->parent 677 ) { 678 $columns = $this->parent->getColumns(); 679 foreach ($columns as $c) 680 $c->setQueue($this); 681 return $columns; 682 } 683 elseif (count($this->columns)) { 684 return $this->columns; 685 } 686 687 // Use the columns of the "Open" queue as a default template 688 if ($use_template && ($template = CustomQueue::lookup(1))) 689 return $template->getColumns(); 690 691 // Last resort — use standard columns 692 foreach (array( 693 QueueColumn::placeholder(array( 694 "id" => 1, 695 "heading" => "Number", 696 "primary" => 'number', 697 "width" => 85, 698 "bits" => QueueColumn::FLAG_SORTABLE, 699 "filter" => "link:ticketP", 700 "annotations" => '[{"c":"TicketSourceDecoration","p":"b"}, {"c":"MergedFlagDecoration","p":">"}]', 701 "conditions" => '[{"crit":["isanswered","nset",null],"prop":{"font-weight":"bold"}}]', 702 )), 703 QueueColumn::placeholder(array( 704 "id" => 2, 705 "heading" => "Created", 706 "primary" => 'created', 707 "filter" => 'date:full', 708 "truncate" =>'wrap', 709 "width" => 120, 710 "bits" => QueueColumn::FLAG_SORTABLE, 711 )), 712 QueueColumn::placeholder(array( 713 "id" => 3, 714 "heading" => "Subject", 715 "primary" => 'cdata__subject', 716 "width" => 250, 717 "bits" => QueueColumn::FLAG_SORTABLE, 718 "filter" => "link:ticket", 719 "annotations" => '[{"c":"TicketThreadCount","p":">"},{"c":"ThreadAttachmentCount","p":"a"},{"c":"OverdueFlagDecoration","p":"<"}]', 720 "conditions" => '[{"crit":["isanswered","nset",null],"prop":{"font-weight":"bold"}}]', 721 "truncate" => 'ellipsis', 722 )), 723 QueueColumn::placeholder(array( 724 "id" => 4, 725 "heading" => "From", 726 "primary" => 'user__name', 727 "width" => 150, 728 "bits" => QueueColumn::FLAG_SORTABLE, 729 )), 730 QueueColumn::placeholder(array( 731 "id" => 5, 732 "heading" => "Priority", 733 "primary" => 'cdata__priority', 734 "width" => 120, 735 "bits" => QueueColumn::FLAG_SORTABLE, 736 )), 737 QueueColumn::placeholder(array( 738 "id" => 8, 739 "heading" => "Assignee", 740 "primary" => 'assignee', 741 "width" => 100, 742 "bits" => QueueColumn::FLAG_SORTABLE, 743 )), 744 ) as $col) 745 $this->addColumn($col); 746 747 return $this->getColumns(); 748 } 749 750 function addColumn(QueueColumn $col) { 751 $this->columns->add($col); 752 $col->queue = $this; 753 } 754 755 function getColumn($id) { 756 // TODO: Got to be easier way to search instrumented list. 757 foreach ($this->getColumns() as $C) 758 if ($C->getId() == $id) 759 return $C; 760 } 761 762 function getSortOptions() { 763 if ($this->inheritSorting() && $this->parent) { 764 return $this->parent->getSortOptions(); 765 } 766 return $this->sorts; 767 } 768 769 function getDefaultSortId() { 770 if ($this->isDefaultSortInherited() && $this->parent 771 && ($sort_id = $this->parent->getDefaultSortId()) 772 ) { 773 return $sort_id; 774 } 775 return $this->sort_id; 776 } 777 778 function getDefaultSort() { 779 if ($this->isDefaultSortInherited() && $this->parent 780 && ($sort = $this->parent->getDefaultSort()) 781 ) { 782 return $sort; 783 } 784 return $this->default_sort; 785 } 786 787 function getStatus() { 788 return $this->hasFlag(self::FLAG_DISABLED) 789 ? __('Disabled') : __('Active'); 790 } 791 792 function getChildren() { 793 return $this->children; 794 } 795 796 function getPublicChildren() { 797 return $this->children->findAll(array( 798 'flags__hasbit' => self::FLAG_QUEUE 799 )); 800 } 801 802 function getMyChildren() { 803 global $thisstaff; 804 if (!$thisstaff instanceof Staff) 805 return array(); 806 807 return $this->children->findAll(array( 808 'staff_id' => $thisstaff->getId(), 809 Q::not(array( 810 'flags__hasbit' => self::FLAG_PUBLIC 811 )) 812 )); 813 } 814 815 function export(CsvExporter $exporter, $options=array()) { 816 global $thisstaff; 817 818 if (!$thisstaff 819 || !($query=$this->getQuery()) 820 || !($fields=$this->getExportFields())) 821 return false; 822 823 // Do not store results in memory 824 $query->setOption(QuerySet::OPT_NOCACHE, true); 825 826 // See if we have cached export preference 827 if (isset($_SESSION['Export:Q'.$this->getId()])) { 828 $opts = $_SESSION['Export:Q'.$this->getId()]; 829 if (isset($opts['fields'])) { 830 $fields = array_intersect_key($fields, 831 array_flip($opts['fields'])); 832 $exportableFields = CustomQueue::getExportableFields(); 833 foreach ($opts['fields'] as $key => $name) { 834 if (is_null($fields[$name]) && isset($exportableFields)) { 835 $fields[$name] = $exportableFields[$name]; 836 } 837 } 838 } 839 } 840 841 // Apply columns 842 $columns = $this->getExportColumns($fields); 843 $headers = array(); // Reset fields based on validity of columns 844 foreach ($columns as $column) { 845 $query = $column->mangleQuery($query, $this->getRoot()); 846 $headers[] = $column->getHeading(); 847 } 848 849 // Apply visibility 850 if (!$this->ignoreVisibilityConstraints($thisstaff)) 851 $query->filter($thisstaff->getTicketsVisibility()); 852 853 // Get stashed sort or else get the default 854 if (!($sort = $_SESSION['sort'][$this->getId()])) 855 $sort = $this->getDefaultSort(); 856 857 // Apply sort 858 if ($sort instanceof QueueSort) 859 $sort->applySort($query); 860 elseif ($sort && isset($sort['queuesort'])) 861 $sort['queuesort']->applySort($query, $sort['dir']); 862 elseif ($sort && $sort['col'] && 863 ($C=$this->getColumn($sort['col']))) 864 $query = $C->applySort($query, $sort['dir']); 865 else 866 $query->order_by('-created'); 867 868 // Distinct ticket_id to avoid duplicate results 869 $query->distinct('ticket_id'); 870 871 // Render Util 872 $render = function ($row) use($columns) { 873 if (!$row) return false; 874 875 $record = array(); 876 foreach ($columns as $path => $column) { 877 $record[] = (string) $column->from_query($row) ?: 878 $row[$path] ?: ''; 879 } 880 return $record; 881 }; 882 883 $exporter->write($headers); 884 foreach ($query as $row) 885 $exporter->write($render($row)); 886 } 887 888 /** 889 * Add critiera to a query based on the constraints configured for this 890 * queue. The criteria of the parent queue is also automatically added 891 * if the queue is configured to inherit the criteria. 892 */ 893 function getBasicQuery() { 894 if ($this->parent && $this->inheritCriteria()) { 895 $query = $this->parent->getBasicQuery(); 896 } 897 else { 898 $root = $this->getRoot(); 899 $query = $root::objects(); 900 } 901 return $this->mangleQuerySet($query); 902 } 903 904 /** 905 * Retrieve a QuerySet instance based on the type of object (root) of 906 * this Q, which is automatically configured with the data and criteria 907 * of the queue and its columns. 908 * 909 * Returns: 910 * <QuerySet> instance 911 */ 912 function getQuery($form=false, $quick_filter=null) { 913 // Start with basic criteria 914 $query = $this->getBasicQuery($form); 915 916 // Apply quick filter 917 if (isset($quick_filter) 918 && ($qf = $this->getQuickFilterField($quick_filter)) 919 ) { 920 $filter = @self::getOrmPath($this->getQuickFilter(), $query); 921 $query = $qf->applyQuickFilter($query, $quick_filter, 922 $filter); 923 } 924 925 // Apply column, annotations and conditions additions 926 foreach ($this->getColumns() as $C) { 927 $C->setQueue($this); 928 $query = $C->mangleQuery($query, $this->getRoot()); 929 } 930 return $query; 931 } 932 933 function getQuickFilter() { 934 if ($this->filter == '::' && $this->parent) { 935 return $this->parent->getQuickFilter(); 936 } 937 return $this->filter; 938 } 939 940 function getQuickFilterField($value=null) { 941 if ($this->filter == '::') { 942 if ($this->parent) { 943 return $this->parent->getQuickFilterField($value); 944 } 945 } 946 elseif ($this->filter 947 && ($fields = self::getSearchableFields($this->getRoot())) 948 && (list(,$f) = @$fields[$this->filter]) 949 && $f->supportsQuickFilter() 950 ) { 951 $f->value = $value; 952 return $f; 953 } 954 } 955 956 /** 957 * Get a description of a field in a search. Expects an entry from the 958 * array retrieved in ::getSearchFields() 959 */ 960 function describeField($info, $name=false) { 961 $name = $name ?: $info['field']->get('label'); 962 return $info['field']->describeSearch($info['method'], $info['value'], $name); 963 } 964 965 function mangleQuerySet(QuerySet $qs, $form=false) { 966 $qs = clone $qs; 967 $searchable = $this->getSupportedMatches(); 968 969 // Figure out fields to search on 970 foreach ($this->getCriteria() as $I) { 971 list($name, $method, $value) = $I; 972 973 // Consider keyword searching 974 if ($name === ':keywords') { 975 global $ost; 976 $qs = $ost->searcher->find($value, $qs, false); 977 } 978 else { 979 // XXX: Move getOrmPath to be more of a utility 980 // Ensure the special join is created to support custom data joins 981 $name = @static::getOrmPath($name, $qs); 982 983 if (preg_match('/__answers!\d+__/', $name)) { 984 $qs->annotate(array($name => SqlAggregate::MAX($name))); 985 } 986 987 // Fetch a criteria Q for the query 988 if (list(,$field) = $searchable[$name]) { 989 // Add annotation if the field supports it. 990 if (is_subclass_of($field, 'AnnotatedField')) 991 $qs = $field->annotate($qs, $name); 992 993 if ($q = $field->getSearchQ($method, $value, $name)) 994 $qs = $qs->filter($q); 995 } 996 } 997 } 998 999 return $qs; 1000 } 1001 1002 function applyDefaultSort($qs) { 1003 // Apply default sort 1004 if ($sorter = $this->getDefaultSort()) { 1005 $qs = $sorter->applySort($qs, false, $this->getRoot()); 1006 } 1007 return $qs; 1008 } 1009 1010 function checkAccess(Staff $agent) { 1011 return $this->isPublic() || $this->checkOwnership($agent); 1012 } 1013 1014 function checkOwnership(Staff $agent) { 1015 1016 return ($agent->getId() == $this->staff_id && 1017 !$this->isAQueue()); 1018 } 1019 1020 function isOwner(Staff $agent) { 1021 return $agent && $this->isPrivate() && $this->checkOwnership($agent); 1022 } 1023 1024 function isSaved() { 1025 return true; 1026 } 1027 1028 function ignoreVisibilityConstraints(Staff $agent) { 1029 // For searches (not queues), some staff can have a permission to 1030 // see all records 1031 return ($this->isASearch() 1032 && $this->isOwner($agent) 1033 && $agent->canSearchEverything()); 1034 } 1035 1036 function inheritCriteria() { 1037 return $this->flags & self::FLAG_INHERIT_CRITERIA && 1038 $this->parent_id; 1039 } 1040 1041 function inheritColumns() { 1042 return $this->hasFlag(self::FLAG_INHERIT_COLUMNS); 1043 } 1044 1045 function useStandardColumns() { 1046 return ($this->hasFlag(self::FLAG_INHERIT_COLUMNS) || 1047 !count($this->columns)); 1048 } 1049 1050 function inheritExport() { 1051 return ($this->hasFlag(self::FLAG_INHERIT_EXPORT) || 1052 !count($this->exports)); 1053 } 1054 1055 function inheritSorting() { 1056 return $this->hasFlag(self::FLAG_INHERIT_SORTING); 1057 } 1058 1059 function isDefaultSortInherited() { 1060 return $this->hasFlag(self::FLAG_INHERIT_DEF_SORT); 1061 } 1062 1063 function buildPath() { 1064 if (!$this->id) 1065 return; 1066 1067 $path = $this->parent ? $this->parent->buildPath() : ''; 1068 return rtrim($path, "/") . "/{$this->id}/"; 1069 } 1070 1071 function getFullName() { 1072 $base = $this->getName(); 1073 if ($this->parent) 1074 $base = sprintf("%s / %s", $this->parent->getFullName(), $base); 1075 return $base; 1076 } 1077 1078 function isASubQueue() { 1079 return $this->parent ? $this->parent->isASubQueue() : 1080 $this->isAQueue(); 1081 } 1082 1083 function isAQueue() { 1084 return $this->hasFlag(self::FLAG_QUEUE); 1085 } 1086 1087 function isASearch() { 1088 return !$this->isAQueue() || !$this->isSaved(); 1089 } 1090 1091 function isPrivate() { 1092 return !$this->isAQueue() && $this->staff_id; 1093 } 1094 1095 function isPublic() { 1096 return $this->hasFlag(self::FLAG_PUBLIC); 1097 } 1098 1099 protected function hasFlag($flag) { 1100 return ($this->flags & $flag) !== 0; 1101 } 1102 1103 protected function clearFlag($flag) { 1104 return $this->flags &= ~$flag; 1105 } 1106 1107 protected function setFlag($flag, $value=true) { 1108 return $value 1109 ? $this->flags |= $flag 1110 : $this->clearFlag($flag); 1111 } 1112 1113 function disable() { 1114 $this->setFlag(self::FLAG_DISABLED); 1115 } 1116 1117 function enable() { 1118 $this->clearFlag(self::FLAG_DISABLED); 1119 } 1120 1121 function getRoughCount() { 1122 if (($count = $this->getRoughCountAPC()) !== false) 1123 return $count; 1124 1125 $query = Ticket::objects(); 1126 $Q = $this->getBasicQuery(); 1127 $expr = SqlCase::N()->when(new SqlExpr(new Q($Q->constraints)), 1128 new SqlField('ticket_id')); 1129 $query = $query->aggregate(array( 1130 "ticket_count" => SqlAggregate::COUNT($expr) 1131 )); 1132 1133 $row = $query->values()->one(); 1134 return $row['ticket_count']; 1135 } 1136 1137 function getRoughCountAPC() { 1138 if (!function_exists('apcu_store')) 1139 return false; 1140 1141 $key = "rough.counts.".SECRET_SALT; 1142 $cached = false; 1143 $counts = apcu_fetch($key, $cached); 1144 if ($cached === true && isset($counts["q{$this->id}"])) 1145 return $counts["q{$this->id}"]; 1146 1147 // Fetch rough counts of all queues. That is, fetch a total of the 1148 // counts based on the queue criteria alone. Do no consider agent 1149 // access. This should be fast and "rought" 1150 $queues = static::objects() 1151 ->filter(['flags__hasbit' => CustomQueue::FLAG_PUBLIC]) 1152 ->exclude(['flags__hasbit' => CustomQueue::FLAG_DISABLED]); 1153 1154 $query = Ticket::objects(); 1155 $prefix = ""; 1156 1157 foreach ($queues as $queue) { 1158 $Q = $queue->getBasicQuery(); 1159 $expr = SqlCase::N()->when(new SqlExpr(new Q($Q->constraints)), 1160 new SqlField('ticket_id')); 1161 $query = $query->aggregate(array( 1162 "q{$queue->id}" => SqlAggregate::COUNT($expr) 1163 )); 1164 } 1165 1166 $counts = $query->values()->one(); 1167 1168 apcu_store($key, $counts, 900); 1169 return @$counts["q{$this->id}"]; 1170 } 1171 1172 function updateExports($fields, $save=true) { 1173 1174 if (!$fields) 1175 return false; 1176 1177 $order = array_keys($fields); 1178 1179 $new = $fields; 1180 foreach ($this->exports as $f) { 1181 $heading = $f->getHeading(); 1182 $key = $f->getPath(); 1183 if (!isset($fields[$key])) { 1184 $this->exports->remove($f); 1185 continue; 1186 } 1187 1188 $f->set('heading', $heading); 1189 $f->set('sort', array_search($key, $order)+1); 1190 unset($new[$key]); 1191 } 1192 1193 $exportableFields = CustomQueue::getExportableFields(); 1194 foreach ($new as $k => $field) { 1195 if (isset($exportableFields[$k])) 1196 $heading = $exportableFields[$k]; 1197 elseif (is_array($field)) 1198 $heading = $field['heading']; 1199 else 1200 $heading = $field; 1201 1202 $f = QueueExport::create(array( 1203 'path' => $k, 1204 'heading' => $heading, 1205 'sort' => array_search($k, $order)+1)); 1206 $this->exports->add($f); 1207 } 1208 1209 $this->exports->sort(function($f) { return $f->sort; }); 1210 1211 if (!count($this->exports) && $this->parent) 1212 $this->hasFlag(self::FLAG_INHERIT_EXPORT); 1213 1214 if ($save) 1215 $this->exports->saveAll(); 1216 1217 return true; 1218 } 1219 1220 function update($vars, &$errors=array()) { 1221 1222 // Set basic search information 1223 if (!$vars['queue-name']) 1224 $errors['queue-name'] = __('A title is required'); 1225 elseif (($q=CustomQueue::lookup(array( 1226 'title' => Format::htmlchars($vars['queue-name']), 1227 'parent_id' => $vars['parent_id'] ?: 0, 1228 'staff_id' => $this->staff_id))) 1229 && $q->getId() != $this->id 1230 ) 1231 $errors['queue-name'] = __('Saved queue with same name exists'); 1232 1233 $this->title = Format::htmlchars($vars['queue-name']); 1234 $this->parent_id = @$vars['parent_id'] ?: 0; 1235 if ($this->parent_id && !$this->parent) 1236 $errors['parent_id'] = __('Select a valid queue'); 1237 1238 // Try to avoid infinite recursion determining ancestry 1239 if ($this->parent_id && isset($this->id)) { 1240 $P = $this; 1241 while ($P = $P->parent) 1242 if ($P->parent_id == $this->id) 1243 $errors['parent_id'] = __('Cannot be a descendent of itself'); 1244 } 1245 1246 // Configure quick filter options 1247 $this->filter = $vars['filter']; 1248 if ($vars['sort_id']) { 1249 if ($vars['filter'] === '::') { 1250 if (!$this->parent) 1251 $errors['filter'] = __('No parent selected'); 1252 } 1253 elseif ($vars['filter'] && !array_key_exists($vars['filter'], 1254 static::getSearchableFields($this->getRoot())) 1255 ) { 1256 $errors['filter'] = __('Select an item from the list'); 1257 } 1258 } 1259 1260 // Set basic queue information 1261 $this->path = $this->buildPath(); 1262 $this->setFlag(self::FLAG_INHERIT_CRITERIA, $this->parent_id); 1263 $this->setFlag(self::FLAG_INHERIT_COLUMNS, 1264 $this->parent_id > 0 && isset($vars['inherit-columns'])); 1265 $this->setFlag(self::FLAG_INHERIT_EXPORT, 1266 $this->parent_id > 0 && isset($vars['inherit-exports'])); 1267 $this->setFlag(self::FLAG_INHERIT_SORTING, 1268 $this->parent_id > 0 && isset($vars['inherit-sorting'])); 1269 1270 // Saved Search - Use standard columns 1271 if ($this instanceof SavedSearch && isset($vars['inherit-columns'])) 1272 $this->setFlag(self::FLAG_INHERIT_COLUMNS); 1273 // Update queue columns (but without save) 1274 if (!isset($vars['columns']) && $this->parent) { 1275 // No columns -- imply column inheritance 1276 $this->setFlag(self::FLAG_INHERIT_COLUMNS); 1277 } 1278 1279 1280 if ($this->getId() 1281 && isset($vars['columns']) 1282 && !$this->hasFlag(self::FLAG_INHERIT_COLUMNS)) { 1283 1284 1285 if ($this->columns->updateColumns($vars['columns'], $errors, array( 1286 'queue_id' => $this->getId(), 1287 'staff_id' => $this->staff_id))) 1288 $this->columns->reset(); 1289 } 1290 1291 // Update export fields for the queue 1292 if (isset($vars['exports']) && 1293 !$this->hasFlag(self::FLAG_INHERIT_EXPORT)) { 1294 $this->updateExports($vars['exports'], false); 1295 } 1296 1297 if (!count($this->exports) && $this->parent) 1298 $this->hasFlag(self::FLAG_INHERIT_EXPORT); 1299 1300 // Update advanced sorting options for the queue 1301 if (isset($vars['sorts']) && !$this->hasFlag(self::FLAG_INHERIT_SORTING)) { 1302 $new = $order = $vars['sorts']; 1303 foreach ($this->sorts as $sort) { 1304 $key = $sort->sort_id; 1305 $idx = array_search($key, $vars['sorts']); 1306 if (false === $idx) { 1307 $this->sorts->remove($sort); 1308 } 1309 else { 1310 $sort->set('sort', $idx); 1311 unset($new[$idx]); 1312 } 1313 } 1314 // Add new columns 1315 foreach ($new as $id) { 1316 if (!$sort = QueueSort::lookup($id)) 1317 continue; 1318 $glue = new QueueSortGlue(array( 1319 'sort_id' => $id, 1320 'queue' => $this, 1321 'sort' => array_search($id, $order), 1322 )); 1323 $this->sorts->add($sort, $glue); 1324 } 1325 // Re-sort the in-memory columns array 1326 $this->sorts->sort(function($c) { return $c->sort; }); 1327 } 1328 if (!count($this->sorts) && $this->parent) { 1329 // No sorting -- imply sorting inheritance 1330 $this->setFlag(self::FLAG_INHERIT_SORTING); 1331 } 1332 1333 // Configure default sorting 1334 $this->setFlag(self::FLAG_INHERIT_DEF_SORT, 1335 $this->parent && $vars['sort_id'] === '::'); 1336 if ($vars['sort_id']) { 1337 if ($vars['sort_id'] === '::') { 1338 if (!$this->parent) 1339 $errors['sort_id'] = __('No parent selected'); 1340 else 1341 $this->sort_id = 0; 1342 } 1343 elseif ($qs = QueueSort::lookup($vars['sort_id'])) { 1344 $this->sort_id = $vars['sort_id']; 1345 } 1346 else { 1347 $errors['sort_id'] = __('Select an item from the list'); 1348 } 1349 } else 1350 $this->sort_id = 0; 1351 1352 list($this->_conditions, $conditions) 1353 = QueueColumn::getConditionsFromPost($vars, $this->id, $this->getRoot()); 1354 1355 // TODO: Move this to SavedSearch::update() and adjust 1356 // AjaxSearch::_saveSearch() 1357 $form = $form ?: $this->getForm($vars); 1358 if (!$vars) { 1359 $errors['criteria'] = __('No criteria specified'); 1360 } 1361 elseif (!$form->isValid()) { 1362 $errors['criteria'] = __('Validation errors exist on criteria'); 1363 } 1364 else { 1365 $this->criteria = static::isolateCriteria($form->getClean(), 1366 $this->getRoot()); 1367 $this->config = JsonDataEncoder::encode([ 1368 'criteria' => $this->criteria, 1369 'conditions' => $conditions, 1370 ]); 1371 // Clear currently set criteria.and conditions. 1372 $this->criteria = $this->_conditions = null; 1373 } 1374 1375 return 0 === count($errors); 1376 } 1377 1378 function psave() { 1379 return parent::save(); 1380 } 1381 1382 function save($refetch=false) { 1383 1384 $nopath = !isset($this->path); 1385 $path_changed = isset($this->dirty['parent_id']); 1386 1387 if ($this->dirty) 1388 $this->updated = SqlFunction::NOW(); 1389 1390 $clearCounts = ($this->dirty || $this->__new__); 1391 if (!($rv = parent::save($refetch || $this->dirty))) 1392 return $rv; 1393 1394 if ($nopath) { 1395 $this->path = $this->buildPath(); 1396 $this->save(); 1397 } 1398 if ($path_changed) { 1399 $this->children->reset(); 1400 $move_children = function($q) use (&$move_children) { 1401 foreach ($q->children as $qq) { 1402 $qq->path = $qq->buildPath(); 1403 $qq->save(); 1404 $move_children($qq); 1405 } 1406 }; 1407 $move_children($this); 1408 } 1409 1410 // Refetch the queue counts 1411 if ($clearCounts) 1412 SavedQueue::clearCounts(); 1413 1414 return $this->columns->saveAll() 1415 && $this->exports->saveAll() 1416 && $this->sorts->saveAll(); 1417 } 1418 1419 /** 1420 * Fetch a tree-organized listing of the queues. Each queue is listed in 1421 * the tree exactly once, and every visible queue is represented. The 1422 * returned structure is an array where the items are two-item arrays 1423 * where the first item is a CustomQueue object an the second is a list 1424 * of the children using the same pattern (two-item arrays of a CustomQueue 1425 * and its children). Visually: 1426 * 1427 * [ [ $queue, [ [ $child, [] ], [ $child, [] ] ], [ $queue, ... ] ] 1428 * 1429 * Parameters: 1430 * $staff - <Staff> staff object which should be used to determine 1431 * visible queues. 1432 * $pid - <int> parent_id of root queue. Default is zero (top-level) 1433 */ 1434 static function getHierarchicalQueues(Staff $staff, $pid=0, 1435 $primary=true) { 1436 $query = static::objects() 1437 ->annotate(array('_sort' => SqlCase::N() 1438 ->when(array('sort' => 0), 999) 1439 ->otherwise(new SqlField('sort')))) 1440 ->filter(Q::any(array( 1441 'flags__hasbit' => self::FLAG_PUBLIC, 1442 'flags__hasbit' => static::FLAG_QUEUE, 1443 'staff_id' => $staff->getId(), 1444 ))) 1445 ->exclude(['flags__hasbit' => self::FLAG_DISABLED]) 1446 ->order_by('parent_id', '_sort', 'title'); 1447 $all = $query->asArray(); 1448 // Find all the queues with a given parent 1449 $for_parent = function($pid) use ($primary, $all, &$for_parent) { 1450 $results = []; 1451 foreach (new \ArrayIterator($all) as $q) { 1452 if ($q->parent_id != $pid) 1453 continue; 1454 1455 if ($pid == 0 && ( 1456 ($primary && !$q->isAQueue()) 1457 || (!$primary && $q->isAQueue()))) 1458 continue; 1459 1460 $results[] = [ $q, $for_parent($q->getId()) ]; 1461 } 1462 1463 return $results; 1464 }; 1465 1466 return $for_parent($pid); 1467 } 1468 1469 static function getOrmPath($name, $query=null) { 1470 // Special case for custom data `__answers!id__value`. Only add the 1471 // join and constraint on the query the first pass, when the query 1472 // being mangled is received. 1473 $path = array(); 1474 if ($query && preg_match('/^(.+?)__(answers!(\d+))/', $name, $path)) { 1475 // Add a join to the model of the queryset where the custom data 1476 // is forked from — duplicate the 'answers' join and add the 1477 // constraint to the query based on the field_id 1478 // $path[1] - part before the answers (user__org__entries) 1479 // $path[2] - answers!xx join part 1480 // $path[3] - the `xx` part of the answers!xx join component 1481 $root = $query->model; 1482 $meta = $root::getMeta()->getByPath($path[1]); 1483 $joins = $meta['joins']; 1484 if (!isset($joins[$path[2]])) { 1485 $meta->addJoin($path[2], $joins['answers']); 1486 } 1487 // Ensure that the query join through answers!xx is only for the 1488 // records which match field_id=xx 1489 $query->constrain(array("{$path[1]}__{$path[2]}" => 1490 array("{$path[1]}__{$path[2]}__field_id" => (int) $path[3]) 1491 )); 1492 // Leave $name unchanged 1493 } 1494 return $name; 1495 } 1496 1497 1498 static function create($vars=false) { 1499 1500 $queue = new static($vars); 1501 $queue->created = SqlFunction::NOW(); 1502 if (!isset($vars['flags'])) { 1503 $queue->setFlag(self::FLAG_PUBLIC); 1504 $queue->setFlag(self::FLAG_QUEUE); 1505 } 1506 1507 return $queue; 1508 } 1509 1510 static function __create($vars) { 1511 $q = static::create($vars); 1512 $q->psave(); 1513 foreach ($vars['columns'] ?: array() as $info) { 1514 $glue = new QueueColumnGlue($info); 1515 $glue->queue_id = $q->getId(); 1516 $glue->save(); 1517 } 1518 if (isset($vars['sorts'])) { 1519 foreach ($vars['sorts'] as $info) { 1520 $glue = new QueueSortGlue($info); 1521 $glue->queue_id = $q->getId(); 1522 $glue->save(); 1523 } 1524 } 1525 return $q; 1526 } 1527} 1528 1529abstract class QueueColumnAnnotation { 1530 static $icon = false; 1531 static $desc = ''; 1532 1533 var $config; 1534 1535 function __construct($config) { 1536 $this->config = $config; 1537 } 1538 1539 static function fromJson($config) { 1540 $class = $config['c']; 1541 if (class_exists($class)) 1542 return new $class($config); 1543 } 1544 1545 static function getDescription() { 1546 return __(static::$desc); 1547 } 1548 static function getIcon() { 1549 return static::$icon; 1550 } 1551 static function getPositions() { 1552 return array( 1553 "<" => __('Start'), 1554 "b" => __('Before'), 1555 "a" => __('After'), 1556 ">" => __('End'), 1557 ); 1558 } 1559 1560 function decorate($text, $dec) { 1561 static $positions = array( 1562 '<' => '<span class="pull-left">%2$s</span>%1$s', 1563 '>' => '<span class="pull-right">%2$s</span>%1$s', 1564 'a' => '%1$s%2$s', 1565 'b' => '%2$s%1$s', 1566 ); 1567 1568 $pos = $this->getPosition(); 1569 if (!isset($positions[$pos])) 1570 return $text; 1571 1572 return sprintf($positions[$pos], $text, $dec); 1573 } 1574 1575 // Render the annotation with the database record $row. $text is the 1576 // text of the cell before annotations were applied. 1577 function render($row, $cell) { 1578 if ($decoration = $this->getDecoration($row, $cell)) 1579 return $this->decorate($cell, $decoration); 1580 1581 return $cell; 1582 } 1583 1584 // Add the annotation to a QuerySet 1585 abstract function annotate($query, $name); 1586 1587 // Fetch some HTML to render the decoration on the page. This function 1588 // can return boolean FALSE to indicate no decoration should be applied 1589 abstract function getDecoration($row, $text); 1590 1591 function getPosition() { 1592 return strtolower($this->config['p']) ?: 'a'; 1593 } 1594 1595 function getClassName() { 1596 return @$this->config['c'] ?: get_class(); 1597 } 1598 1599 static function getAnnotations($root) { 1600 // Ticket annotations 1601 static $annotations; 1602 if (!isset($annotations[$root])) { 1603 foreach (get_declared_classes() as $class) 1604 if (is_subclass_of($class, get_called_class())) 1605 $annotations[$root][] = $class; 1606 } 1607 return $annotations[$root]; 1608 } 1609 1610 /** 1611 * Estimate the width of the rendered annotation in pixels 1612 */ 1613 function getWidth($row) { 1614 return $this->isVisible($row) ? 25 : 0; 1615 } 1616 1617 function isVisible($row) { 1618 return true; 1619 } 1620 1621 static function addToQuery($query, $name=false) { 1622 $name = $name ?: static::$qname; 1623 $annotation = new Static(array()); 1624 return $annotation->annotate($query, $name); 1625 } 1626 1627 static function from_query($row, $name=false) { 1628 $name = $name ?: static::$qname; 1629 return $row[$name]; 1630 } 1631} 1632 1633class TicketThreadCount 1634extends QueueColumnAnnotation { 1635 static $icon = 'comments-alt'; 1636 static $qname = '_thread_count'; 1637 static $desc = /* @trans */ 'Thread Count'; 1638 1639 function annotate($query, $name=false) { 1640 $name = $name ?: static::$qname; 1641 return $query->annotate(array( 1642 $name => TicketThread::objects() 1643 ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) 1644 ->exclude(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN)) 1645 ->aggregate(array('count' => SqlAggregate::COUNT('entries__id'))) 1646 )); 1647 } 1648 1649 function getDecoration($row, $text) { 1650 $threadcount = $row[static::$qname]; 1651 if ($threadcount > 1) { 1652 return sprintf( 1653 '<small class="faded-more"><i class="icon-comments-alt"></i> %s</small>', 1654 $threadcount 1655 ); 1656 } 1657 } 1658 1659 function isVisible($row) { 1660 return $row[static::$qname] > 1; 1661 } 1662} 1663 1664class TicketReopenCount 1665extends QueueColumnAnnotation { 1666 static $icon = 'folder-open-alt'; 1667 static $qname = '_reopen_count'; 1668 static $desc = /* @trans */ 'Reopen Count'; 1669 1670 function annotate($query, $name=false) { 1671 $name = $name ?: static::$qname; 1672 return $query->annotate(array( 1673 $name => TicketThread::objects() 1674 ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) 1675 ->filter(array('events__annulled' => 0, 'events__event_id' => Event::getIdByName('reopened'))) 1676 ->aggregate(array('count' => SqlAggregate::COUNT('events__id'))) 1677 )); 1678 } 1679 1680 function getDecoration($row, $text) { 1681 $reopencount = $row[static::$qname]; 1682 if ($reopencount) { 1683 return sprintf( 1684 ' <small class="faded-more"><i class="icon-%s"></i> %s</small>', 1685 static::$icon, 1686 $reopencount > 1 ? $reopencount : '' 1687 ); 1688 } 1689 } 1690 1691 function isVisible($row) { 1692 return $row[static::$qname]; 1693 } 1694} 1695 1696class ThreadAttachmentCount 1697extends QueueColumnAnnotation { 1698 static $icon = 'paperclip'; 1699 static $qname = '_att_count'; 1700 static $desc = /* @trans */ 'Attachment Count'; 1701 1702 function annotate($query, $name=false) { 1703 // TODO: Convert to Thread attachments 1704 $name = $name ?: static::$qname; 1705 return $query->annotate(array( 1706 $name => TicketThread::objects() 1707 ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) 1708 ->filter(array('entries__attachments__inline' => 0)) 1709 ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))) 1710 )); 1711 } 1712 1713 function getDecoration($row, $text) { 1714 $count = $row[static::$qname]; 1715 if ($count) { 1716 return sprintf( 1717 '<i class="small icon-paperclip icon-flip-horizontal" data-toggle="tooltip" title="%s"></i>', 1718 $count); 1719 } 1720 } 1721 1722 function isVisible($row) { 1723 return $row[static::$qname] > 0; 1724 } 1725} 1726 1727class TicketTasksCount 1728extends QueueColumnAnnotation { 1729 static $icon = 'list-ol'; 1730 static $qname = '_task_count'; 1731 static $desc = /* @trans */ 'Tasks Count'; 1732 1733 function annotate($query, $name=false) { 1734 $name = $name ?: static::$qname; 1735 return $query->annotate(array( 1736 $name => Task::objects() 1737 ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) 1738 ->aggregate(array('count' => SqlAggregate::COUNT('id'))) 1739 )); 1740 } 1741 1742 function getDecoration($row, $text) { 1743 $count = $row[static::$qname]; 1744 if ($count) { 1745 return sprintf( 1746 '<small class="faded-more"><i class="icon-%s"></i> %s</small>', 1747 static::$icon, $count); 1748 } 1749 } 1750 1751 function isVisible($row) { 1752 return $row[static::$qname]; 1753 } 1754} 1755 1756class ThreadCollaboratorCount 1757extends QueueColumnAnnotation { 1758 static $icon = 'group'; 1759 static $qname = '_collabs'; 1760 static $desc = /* @trans */ 'Collaborator Count'; 1761 1762 function annotate($query, $name=false) { 1763 $name = $name ?: static::$qname; 1764 return $query->annotate(array( 1765 $name => TicketThread::objects() 1766 ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) 1767 ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id'))) 1768 )); 1769 } 1770 1771 function getDecoration($row, $text) { 1772 $count = $row[static::$qname]; 1773 if ($count) { 1774 return sprintf( 1775 '<span class="pull-right faded-more" data-toggle="tooltip" title="%d"><i class="icon-group"></i></span>', 1776 $count); 1777 } 1778 } 1779 1780 function isVisible($row) { 1781 return $row[static::$qname] > 0; 1782 } 1783} 1784 1785class OverdueFlagDecoration 1786extends QueueColumnAnnotation { 1787 static $icon = 'exclamation'; 1788 static $desc = /* @trans */ 'Overdue Icon'; 1789 1790 function annotate($query, $name=false) { 1791 return $query->values('isoverdue'); 1792 } 1793 1794 function getDecoration($row, $text) { 1795 if ($row['isoverdue']) 1796 return '<span class="Icon overdueTicket"></span>'; 1797 } 1798 1799 function isVisible($row) { 1800 return $row['isoverdue']; 1801 } 1802} 1803 1804class MergedFlagDecoration 1805extends QueueColumnAnnotation { 1806 static $icon = 'code-fork'; 1807 static $desc = /* @trans */ 'Merged Icon'; 1808 1809 function annotate($query, $name=false) { 1810 return $query->values('ticket_pid', 'flags'); 1811 } 1812 1813 function getDecoration($row, $text) { 1814 $flags = $row['flags']; 1815 $combine = ($flags & Ticket::FLAG_COMBINE_THREADS) != 0; 1816 $separate = ($flags & Ticket::FLAG_SEPARATE_THREADS) != 0; 1817 $linked = ($flags & Ticket::FLAG_LINKED) != 0; 1818 1819 if ($combine || $separate) { 1820 return sprintf('<a data-placement="bottom" data-toggle="tooltip" title="%s" <i class="icon-code-fork"></i></a>', 1821 $combine ? __('Combine') : __('Separate')); 1822 } elseif ($linked) 1823 return '<i class="icon-link"></i>'; 1824 } 1825 1826 function isVisible($row) { 1827 return $row['ticket_pid']; 1828 } 1829} 1830 1831class LinkedFlagDecoration 1832extends QueueColumnAnnotation { 1833 static $icon = 'link'; 1834 static $desc = /* @trans */ 'Linked Icon'; 1835 1836 function annotate($query, $name=false) { 1837 return $query->values('ticket_pid', 'flags'); 1838 } 1839 1840 function getDecoration($row, $text) { 1841 $flags = $row['flags']; 1842 $linked = ($flags & Ticket::FLAG_LINKED) != 0; 1843 if ($linked && $_REQUEST['a'] == 'search') 1844 return '<i class="icon-link"></i>'; 1845 } 1846 1847 function isVisible($row) { 1848 return $row['ticket_pid']; 1849 } 1850} 1851 1852class TicketSourceDecoration 1853extends QueueColumnAnnotation { 1854 static $icon = 'phone'; 1855 static $desc = /* @trans */ 'Ticket Source'; 1856 1857 function annotate($query, $name=false) { 1858 return $query->values('source'); 1859 } 1860 1861 function getDecoration($row, $text) { 1862 return sprintf('<span class="Icon %sTicket"></span>', 1863 strtolower($row['source'])); 1864 } 1865} 1866 1867class LockDecoration 1868extends QueueColumnAnnotation { 1869 static $icon = "lock"; 1870 static $desc = /* @trans */ 'Locked'; 1871 1872 function annotate($query, $name=false) { 1873 global $thisstaff; 1874 1875 return $query 1876 ->annotate(array( 1877 '_locked' => new SqlExpr(new Q(array( 1878 'lock__expire__gt' => SqlFunction::NOW(), 1879 Q::not(array('lock__staff_id' => $thisstaff->getId())), 1880 ))) 1881 )); 1882 } 1883 1884 function getDecoration($row, $text) { 1885 if ($row['_locked']) 1886 return sprintf('<span class="Icon lockedTicket"></span>'); 1887 } 1888 1889 function isVisible($row) { 1890 return $row['_locked']; 1891 } 1892} 1893 1894class AssigneeAvatarDecoration 1895extends QueueColumnAnnotation { 1896 static $icon = "user"; 1897 static $desc = /* @trans */ 'Assignee Avatar'; 1898 1899 function annotate($query, $name=false) { 1900 return $query->values('staff_id', 'team_id'); 1901 } 1902 1903 function getDecoration($row, $text) { 1904 if ($row['staff_id'] && ($staff = Staff::lookup($row['staff_id']))) 1905 return sprintf('<span class="avatar">%s</span>', 1906 $staff->getAvatar(16)); 1907 elseif ($row['team_id'] && ($team = Team::lookup($row['team_id']))) { 1908 $avatars = []; 1909 foreach ($team->getMembers() as $T) 1910 $avatars[] = $T->getAvatar(16); 1911 return sprintf('<span class="avatar group %s">%s</span>', 1912 count($avatars), implode('', $avatars)); 1913 } 1914 } 1915 1916 function isVisible($row) { 1917 return $row['staff_id'] + $row['team_id'] > 0; 1918 } 1919 1920 function getWidth($row) { 1921 if (!$this->isVisible($row)) 1922 return 0; 1923 1924 // If assigned to a team with no members, return 0 width 1925 $width = 10; 1926 if ($row['team_id'] && ($team = Team::lookup($row['team_id']))) 1927 $width += (count($team->getMembers()) - 1) * 10; 1928 1929 return $width ? $width + 10 : $width; 1930 } 1931} 1932 1933class UserAvatarDecoration 1934extends QueueColumnAnnotation { 1935 static $icon = "user"; 1936 static $desc = /* @trans */ 'User Avatar'; 1937 1938 function annotate($query, $name=false) { 1939 return $query->values('user_id'); 1940 } 1941 1942 function getDecoration($row, $text) { 1943 if ($row['user_id'] && ($user = User::lookup($row['user_id']))) 1944 return sprintf('<span class="avatar">%s</span>', 1945 $user->getAvatar(16)); 1946 } 1947 1948 function isVisible($row) { 1949 return $row['user_id'] > 0; 1950 } 1951} 1952 1953class DataSourceField 1954extends ChoiceField { 1955 function getChoices($verbose=false, $options=array()) { 1956 $config = $this->getConfiguration(); 1957 $root = $config['root']; 1958 $fields = array(); 1959 foreach (CustomQueue::getSearchableFields($root) as $path=>$f) { 1960 list($label,) = $f; 1961 $fields[$path] = $label; 1962 } 1963 return $fields; 1964 } 1965} 1966 1967class QueueColumnCondition { 1968 var $config; 1969 var $queue; 1970 var $properties = array(); 1971 1972 static $uid = 1; 1973 1974 function __construct($config, $queue=null) { 1975 $this->config = $config; 1976 $this->queue = $queue; 1977 if (is_array($config['prop'])) 1978 $this->properties = $config['prop']; 1979 } 1980 1981 function getProperties() { 1982 return $this->properties; 1983 } 1984 1985 // Add the annotation to a QuerySet 1986 function annotate($query) { 1987 if (!($Q = $this->getSearchQ($query))) 1988 return $query; 1989 1990 // Add an annotation to the query 1991 return $query->annotate(array( 1992 $this->getAnnotationName() => new SqlExpr(array($Q)) 1993 )); 1994 } 1995 1996 function getField($name=null) { 1997 // FIXME 1998 #$root = $this->getColumn()->getRoot(); 1999 $root = 'Ticket'; 2000 $searchable = CustomQueue::getSearchableFields($root); 2001 2002 if (!isset($name)) 2003 list($name) = $this->config['crit']; 2004 2005 // Lookup the field to search this condition 2006 if (isset($searchable[$name])) { 2007 return $searchable[$name]; 2008 } 2009 } 2010 2011 function getFieldName() { 2012 list($name) = $this->config['crit']; 2013 return $name; 2014 } 2015 2016 function getCriteria() { 2017 return $this->config['crit']; 2018 } 2019 2020 function getSearchQ($query) { 2021 list($name, $method, $value) = $this->config['crit']; 2022 2023 // XXX: Move getOrmPath to be more of a utility 2024 // Ensure the special join is created to support custom data joins 2025 $name = @CustomQueue::getOrmPath($name, $query); 2026 2027 $name2 = null; 2028 if (preg_match('/__answers!\d+__/', $name)) { 2029 // Ensure that only one record is returned from the join through 2030 // the entry and answers joins 2031 $name2 = $this->getAnnotationName().'2'; 2032 $query->annotate(array($name2 => SqlAggregate::MAX($name))); 2033 } 2034 2035 // Fetch a criteria Q for the query 2036 if (list(,$field) = $this->getField($name)) 2037 return $field->getSearchQ($method, $value, $name2 ?: $name); 2038 } 2039 2040 /** 2041 * Take the criteria from the SavedSearch fields setup and isolate the 2042 * field name being search, the method used for searhing, and the method- 2043 * specific data entered in the UI. 2044 */ 2045 static function isolateCriteria($criteria, $base='Ticket') { 2046 $searchable = CustomQueue::getSearchableFields($base); 2047 foreach ($criteria as $k=>$v) { 2048 if (substr($k, -7) === '+method') { 2049 list($name,) = explode('+', $k, 2); 2050 if (!isset($searchable[$name])) 2051 continue; 2052 2053 // Lookup the field to search this condition 2054 list($label, $field) = $searchable[$name]; 2055 2056 // Get the search method and value 2057 $method = $v; 2058 // Not all search methods require a value 2059 $value = $criteria["{$name}+{$method}"]; 2060 2061 return array($name, $method, $value); 2062 } 2063 } 2064 } 2065 2066 function render($row, $text, &$styles=array()) { 2067 if ($V = $row[$this->getAnnotationName()]) { 2068 foreach ($this->getProperties() as $css=>$value) { 2069 $field = QueueColumnConditionProperty::getField($css); 2070 $field->value = $value; 2071 $V = $field->getClean(); 2072 if (is_array($V)) 2073 $V = current($V); 2074 $styles[$css] = $V; 2075 } 2076 } 2077 return $text; 2078 } 2079 2080 function getAnnotationName() { 2081 // This should be predictable based on the criteria so that the 2082 // query can deduplicate the same annotations used in different 2083 // conditions 2084 if (!isset($this->annotation_name)) { 2085 $this->annotation_name = $this->getShortHash(); 2086 } 2087 return $this->annotation_name; 2088 } 2089 2090 function __toString() { 2091 list($name, $method, $value) = $this->config['crit']; 2092 if (is_array($value)) 2093 $value = implode('+', $value); 2094 2095 return "{$name} {$method} {$value}"; 2096 } 2097 2098 function getHash($binary=false) { 2099 return sha1($this->__toString(), $binary); 2100 } 2101 2102 function getShortHash() { 2103 return substr(base64_encode($this->getHash(true)), 0, 7); 2104 } 2105 2106 static function getUid() { 2107 return static::$uid++; 2108 } 2109 2110 static function fromJson($config, $queue=null) { 2111 if (is_string($config)) 2112 $config = JsonDataParser::decode($config); 2113 if (!is_array($config)) 2114 throw new BadMethodCallException('$config must be string or array'); 2115 2116 return new static($config, $queue); 2117 } 2118} 2119 2120class QueueColumnConditionProperty 2121extends ChoiceField { 2122 static $properties = array( 2123 'background-color' => 'ColorChoiceField', 2124 'color' => 'ColorChoiceField', 2125 'font-family' => array( 2126 'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy', 2127 ), 2128 'font-size' => array( 2129 'small', 'medium', 'large', 'smaller', 'larger', 2130 ), 2131 'font-style' => array( 2132 'normal', 'italic', 'oblique', 2133 ), 2134 'font-weight' => array( 2135 'lighter', 'normal', 'bold', 'bolder', 2136 ), 2137 'text-decoration' => array( 2138 'none', 'underline', 2139 ), 2140 'text-transform' => array( 2141 'uppercase', 'lowercase', 'captalize', 2142 ), 2143 ); 2144 2145 function __construct($property) { 2146 $this->property = $property; 2147 } 2148 2149 static function getProperties() { 2150 return array_keys(static::$properties); 2151 } 2152 2153 static function getField($prop) { 2154 $choices = static::$properties[$prop]; 2155 if (!isset($choices)) 2156 return null; 2157 if (is_array($choices)) 2158 return new ChoiceField(array( 2159 'name' => $prop, 2160 'choices' => array_combine($choices, $choices), 2161 )); 2162 elseif (class_exists($choices)) 2163 return new $choices(array('name' => $prop)); 2164 } 2165 2166 function getChoices($verbose=false, $options=array()) { 2167 if (isset($this->property)) 2168 return static::$properties[$this->property]; 2169 2170 $keys = array_keys(static::$properties); 2171 return array_combine($keys, $keys); 2172 } 2173} 2174 2175class LazyDisplayWrapper { 2176 function __construct($field, $value) { 2177 $this->field = $field; 2178 $this->value = $value; 2179 $this->safe = false; 2180 } 2181 2182 /** 2183 * Allow a filter to change the value of this to a "safe" value which 2184 * will not be automatically encoded with htmlchars() 2185 */ 2186 function changeTo($what, $safe=false) { 2187 $this->field = null; 2188 $this->value = $what; 2189 $this->safe = $safe; 2190 } 2191 2192 function __toString() { 2193 return $this->display(); 2194 } 2195 2196 function display(&$styles=array()) { 2197 if (isset($this->field)) 2198 return $this->field->display( 2199 $this->field->to_php($this->value), $styles); 2200 if ($this->safe) 2201 return $this->value; 2202 return Format::htmlchars($this->value); 2203 } 2204} 2205 2206/** 2207 * A column of a custom queue. Columns have many customizable features 2208 * including: 2209 * 2210 * * Data Source (primary and secondary) 2211 * * Heading 2212 * * Link (to an object like the ticket) 2213 * * Size and truncate settings 2214 * * annotations (like counts and flags) 2215 * * Conditions (which change the formatting like bold text) 2216 * 2217 * Columns are stored in a separate table from the queue itself, but other 2218 * breakout items for the annotations and conditions, for instance, are stored 2219 * as JSON text in the QueueColumn model. 2220 */ 2221class QueueColumn 2222extends VerySimpleModel { 2223 static $meta = array( 2224 'table' => COLUMN_TABLE, 2225 'pk' => array('id'), 2226 'ordering' => array('name'), 2227 ); 2228 2229 const FLAG_SORTABLE = 0x0001; 2230 2231 var $_annotations; 2232 var $_conditions; 2233 var $_queue; // Apparent queue if being inherited 2234 var $_fields; 2235 2236 function getId() { 2237 return $this->id; 2238 } 2239 2240 function getFilter() { 2241 if ($this->filter 2242 && ($F = QueueColumnFilter::getInstance($this->filter))) 2243 return $F; 2244 } 2245 2246 function getName() { 2247 return $this->name; 2248 } 2249 2250 // These getters fetch data from the annotated overlay from the 2251 // queue_column table 2252 function getQueue() { 2253 if (!isset($this->_queue)) { 2254 $queue = $this->queue; 2255 2256 if (!$queue && ($queue_id = $this->queue_id) && is_numeric($queue_id)) 2257 $queue = CustomQueue::lookup($queue_id); 2258 2259 $this->_queue = $queue; 2260 } 2261 2262 return $this->_queue; 2263 } 2264 /** 2265 * If a column is inherited into a child queue and there are conditions 2266 * added to that queue, then the column will need to be linked at 2267 * run-time to the child queue rather than the parent. 2268 */ 2269 function setQueue(CustomQueue $queue) { 2270 $this->_queue = $queue; 2271 } 2272 2273 function getFields() { 2274 if (!isset($this->_fields)) { 2275 $root = ($q = $this->getQueue()) ? $q->getRoot() : 'Ticket'; 2276 $fields = CustomQueue::getSearchableFields($root); 2277 $primary = CustomQueue::getOrmPath($this->primary); 2278 $secondary = CustomQueue::getOrmPath($this->secondary); 2279 if (($F = $fields[$primary]) && (list(,$field) = $F)) 2280 $this->_fields[$primary] = $field; 2281 if (($F = $fields[$secondary]) && (list(,$field) = $F)) 2282 $this->_fields[$secondary] = $field; 2283 } 2284 return $this->_fields; 2285 } 2286 2287 function getField($path=null) { 2288 $fields = $this->getFields(); 2289 return @$fields[$path ?: $this->primary]; 2290 } 2291 2292 function getWidth() { 2293 return $this->width ?: 100; 2294 } 2295 2296 function getHeading() { 2297 return $this->heading; 2298 } 2299 2300 function getTranslateTag($subtag) { 2301 return _H(sprintf('column.%s.%s.%s', $subtag, $this->queue_id, $this->id)); 2302 } 2303 function getLocal($subtag) { 2304 $tag = $this->getTranslateTag($subtag); 2305 $T = CustomDataTranslation::translate($tag); 2306 return $T != $tag ? $T : $this->get($subtag); 2307 } 2308 function getLocalHeading() { 2309 return $this->getLocal('heading'); 2310 } 2311 2312 protected function setFlag($flag, $value=true, $field='flags') { 2313 return $value 2314 ? $this->{$field} |= $flag 2315 : $this->clearFlag($flag, $field); 2316 } 2317 2318 protected function clearFlag($flag, $field='flags') { 2319 return $this->{$field} &= ~$flag; 2320 } 2321 2322 function isSortable() { 2323 return $this->bits & self::FLAG_SORTABLE; 2324 } 2325 2326 function setSortable($sortable) { 2327 $this->setFlag(self::FLAG_SORTABLE, $sortable, 'bits'); 2328 } 2329 2330 function render($row) { 2331 // Basic data 2332 $text = $this->renderBasicValue($row); 2333 2334 // Filter 2335 if ($text && ($filter = $this->getFilter())) { 2336 $text = $filter->filter($text, $row) ?: $text; 2337 } 2338 2339 $styles = array(); 2340 if ($text instanceof LazyDisplayWrapper) { 2341 $text = $text->display($styles); 2342 } 2343 2344 // Truncate 2345 $text = $this->applyTruncate($text, $row); 2346 2347 // annotations and conditions 2348 foreach ($this->getAnnotations() as $D) { 2349 $text = $D->render($row, $text); 2350 } 2351 foreach ($this->getConditions() as $C) { 2352 $text = $C->render($row, $text, $styles); 2353 } 2354 $style = Format::array_implode(':', ';', $styles); 2355 return array($text, $style); 2356 } 2357 2358 function renderBasicValue($row) { 2359 $fields = $this->getFields(); 2360 $primary = CustomQueue::getOrmPath($this->primary); 2361 $secondary = CustomQueue::getOrmPath($this->secondary); 2362 2363 // Return a lazily ::display()ed value so that the value to be 2364 // rendered by the field could be changed or display()ed when 2365 // converted to a string. 2366 if (($F = $fields[$primary]) 2367 && ($T = $F->from_query($row, $primary)) 2368 ) { 2369 return new LazyDisplayWrapper($F, $T); 2370 } 2371 if (($F = $fields[$secondary]) 2372 && ($T = $F->from_query($row, $secondary)) 2373 ) { 2374 return new LazyDisplayWrapper($F, $T); 2375 } 2376 2377 return new LazyDisplayWrapper($F, ''); 2378 } 2379 2380 function from_query($row) { 2381 if (!($f = $this->getField($this->primary))) 2382 return ''; 2383 2384 $val = $f->to_php($f->from_query($row, $this->primary)); 2385 if (!is_string($val) || is_numeric($val)) 2386 $val = $f->display($val); 2387 2388 return $val; 2389 } 2390 2391 function applyTruncate($text, $row) { 2392 $offset = 0; 2393 foreach ($this->getAnnotations() as $a) 2394 $offset += $a->getWidth($row); 2395 2396 $width = $this->width - $offset; 2397 $class = array(); 2398 switch ($this->truncate) { 2399 case 'lclip': 2400 $linfo = Internationalization::getCurrentLanguageInfo(); 2401 // Use `rtl` class to cut the beginning of LTR text. But, wrap 2402 // the text with an appropriate direction so the ending 2403 // punctuation is not rearranged. 2404 $dir = $linfo['direction'] ?: 'ltr'; 2405 $text = sprintf('<span class="%s">%s</span>', $dir, $text); 2406 $class[] = $dir == 'rtl' ? 'ltr' : 'rtl'; 2407 case 'clip': 2408 $class[] = 'bleed'; 2409 case 'ellipsis': 2410 $class[] = 'truncate'; 2411 return sprintf('<span class="%s" style="max-width:%dpx">%s</span>', 2412 implode(' ', $class), $width, $text); 2413 default: 2414 case 'wrap': 2415 return $text; 2416 } 2417 } 2418 2419 function addToQuery($query, $field, $path) { 2420 if (preg_match('/__answers!\d+__/', $path)) { 2421 // Ensure that only one record is returned from the join through 2422 // the entry and answers joins 2423 return $query->annotate(array( 2424 $path => SqlAggregate::MAX($path) 2425 )); 2426 } 2427 return $field->addToQuery($query, $path); 2428 } 2429 2430 function mangleQuery($query, $root=null) { 2431 // Basic data 2432 $fields = $this->getFields(); 2433 if ($field = $fields[$this->primary]) { 2434 $query = $this->addToQuery($query, $field, 2435 CustomQueue::getOrmPath($this->primary, $query)); 2436 } 2437 if ($field = $fields[$this->secondary]) { 2438 $query = $this->addToQuery($query, $field, 2439 CustomQueue::getOrmPath($this->secondary, $query)); 2440 } 2441 2442 if ($filter = $this->getFilter()) 2443 $query = $filter->mangleQuery($query, $this); 2444 2445 // annotations 2446 foreach ($this->getAnnotations() as $D) { 2447 $query = $D->annotate($query); 2448 } 2449 2450 // Conditions 2451 foreach ($this->getConditions() as $C) { 2452 $query = $C->annotate($query); 2453 } 2454 2455 return $query; 2456 } 2457 2458 function applySort($query, $reverse=false) { 2459 $root = ($q = $this->getQueue()) ? $q->getRoot() : 'Ticket'; 2460 $fields = CustomQueue::getSearchableFields($root); 2461 2462 $keys = array(); 2463 if ($primary = $fields[$this->primary]) { 2464 list(,$field) = $primary; 2465 $keys[] = array(CustomQueue::getOrmPath($this->primary, $query), 2466 $field); 2467 } 2468 2469 if ($secondary = $fields[$this->secondary]) { 2470 list(,$field) = $secondary; 2471 $keys[] = array(CustomQueue::getOrmPath($this->secondary, 2472 $query), $field); 2473 } 2474 2475 if (count($keys) > 1) { 2476 $fields = array(); 2477 foreach ($keys as $key) { 2478 list($path, $field) = $key; 2479 foreach ($field->getSortKeys($path) as $field) 2480 $fields[] = new SqlField($field); 2481 } 2482 // Force nulls to the buttom. 2483 $fields[] = 'zzz'; 2484 2485 $alias = sprintf('C%d', $this->getId()); 2486 $expr = call_user_func_array(array('SqlFunction', 'COALESCE'), 2487 $fields); 2488 $query->annotate(array($alias => $expr)); 2489 2490 $reverse = $reverse ? '-' : ''; 2491 $query = $query->order_by("{$reverse}{$alias}"); 2492 } elseif($keys[0]) { 2493 list($path, $field) = $keys[0]; 2494 $query = $field->applyOrderBy($query, $reverse, $path); 2495 } 2496 2497 return $query; 2498 } 2499 2500 function getDataConfigForm($source=false) { 2501 return new QueueColDataConfigForm($source ?: $this->getDbFields(), 2502 array('id' => $this->id)); 2503 } 2504 2505 function getAnnotations() { 2506 if (!isset($this->_annotations)) { 2507 $this->_annotations = array(); 2508 if ($this->annotations 2509 && ($anns = JsonDataParser::decode($this->annotations)) 2510 ) { 2511 foreach ($anns as $D) 2512 if ($T = QueueColumnAnnotation::fromJson($D)) 2513 $this->_annotations[] = $T; 2514 } 2515 } 2516 return $this->_annotations; 2517 } 2518 2519 function getConditions($include_queue=true) { 2520 if (!isset($this->_conditions)) { 2521 $this->_conditions = array(); 2522 if ($this->conditions 2523 && ($conds = JsonDataParser::decode($this->conditions)) 2524 ) { 2525 foreach ($conds as $C) 2526 if ($T = QueueColumnCondition::fromJson($C)) 2527 $this->_conditions[] = $T; 2528 } 2529 // Support row-spanning conditions 2530 if ($include_queue && ($q = $this->getQueue()) 2531 && ($q_conds = $q->getConditions()) 2532 ) { 2533 $this->_conditions = array_merge($q_conds, $this->_conditions); 2534 } 2535 } 2536 return $this->_conditions; 2537 } 2538 2539 static function __create($vars) { 2540 $c = new static($vars); 2541 $c->save(); 2542 return $c; 2543 } 2544 2545 static function placeholder($vars) { 2546 return static::__hydrate($vars); 2547 } 2548 2549 function update($vars, $root='Ticket') { 2550 $form = $this->getDataConfigForm($vars); 2551 foreach ($form->getClean() as $k=>$v) 2552 $this->set($k, $v); 2553 2554 // Do the annotations 2555 $this->_annotations = $annotations = array(); 2556 if (isset($vars['annotations'])) { 2557 foreach (@$vars['annotations'] as $i=>$class) { 2558 if ($vars['deco_column'][$i] != $this->id) 2559 continue; 2560 if (!class_exists($class) || !is_subclass_of($class, 'QueueColumnAnnotation')) 2561 continue; 2562 $json = array('c' => $class, 'p' => $vars['deco_pos'][$i]); 2563 $annotations[] = $json; 2564 $this->_annotations[] = QueueColumnAnnotation::fromJson($json); 2565 } 2566 } 2567 2568 // Do the conditions 2569 $this->_conditions = $conditions = array(); 2570 if (isset($vars['conditions'])) { 2571 list($this->_conditions, $conditions) 2572 = self::getConditionsFromPost($vars, $this->id, $root); 2573 } 2574 2575 // Store as JSON array 2576 $this->annotations = JsonDataEncoder::encode($annotations); 2577 $this->conditions = JsonDataEncoder::encode($conditions); 2578 } 2579 2580 static function getConditionsFromPost(array $vars, $myid, $root='Ticket') { 2581 $condition_objects = $conditions = array(); 2582 2583 if (!isset($vars['conditions'])) 2584 return array($condition_objects, $conditions); 2585 2586 foreach (@$vars['conditions'] as $i=>$id) { 2587 if ($vars['condition_column'][$i] != $myid) 2588 // Not a condition for this column 2589 continue; 2590 // Determine the criteria 2591 $name = $vars['condition_field'][$i]; 2592 $fields = CustomQueue::getSearchableFields($root); 2593 if (!isset($fields[$name])) 2594 // No such field exists for this queue root type 2595 continue; 2596 $parts = CustomQueue::getSearchField($fields[$name], $name); 2597 $search_form = new SimpleForm($parts, $vars, array('id' => $id)); 2598 $search_form->getField("{$name}+search")->value = true; 2599 $crit = $search_form->getClean(); 2600 // Check the box to enable searching on the field 2601 $crit["{$name}+search"] = true; 2602 2603 // Isolate only the critical parts of the criteria 2604 $crit = QueueColumnCondition::isolateCriteria($crit); 2605 2606 // Determine the properties 2607 $props = array(); 2608 foreach ($vars['properties'] as $i=>$cid) { 2609 if ($cid != $id) 2610 // Not a property for this condition 2611 continue; 2612 2613 // Determine the property configuration 2614 $prop = $vars['property_name'][$i]; 2615 if (!($F = QueueColumnConditionProperty::getField($prop))) { 2616 // Not a valid property 2617 continue; 2618 } 2619 $prop_form = new SimpleForm(array($F), $vars, array('id' => $cid)); 2620 $props[$prop] = $prop_form->getField($prop)->getClean(); 2621 } 2622 $json = array('crit' => $crit, 'prop' => $props); 2623 $condition_objects[] = QueueColumnCondition::fromJson($json); 2624 $conditions[] = $json; 2625 } 2626 return array($condition_objects, $conditions); 2627 } 2628} 2629 2630 2631class QueueConfig 2632extends VerySimpleModel { 2633 static $meta = array( 2634 'table' => QUEUE_CONFIG_TABLE, 2635 'pk' => array('queue_id', 'staff_id'), 2636 'joins' => array( 2637 'queue' => array( 2638 'constraint' => array( 2639 'queue_id' => 'CustomQueue.id'), 2640 ), 2641 'staff' => array( 2642 'constraint' => array( 2643 'staff_id' => 'Staff.staff_id', 2644 ) 2645 ), 2646 'columns' => array( 2647 'reverse' => 'QueueColumnGlue.config', 2648 'constrain' => array('staff_id' =>'QueueColumnGlue.staff_id'), 2649 'broker' => 'QueueColumnListBroker', 2650 ), 2651 ), 2652 ); 2653 2654 function getSettings() { 2655 return JsonDataParser::decode($this->setting); 2656 } 2657 2658 2659 function update($vars, &$errors) { 2660 2661 // settings of interest 2662 $setting = array( 2663 'sort_id' => (int) $vars['sort_id'], 2664 'filter' => $vars['filter'], 2665 'inherit-sort' => ($vars['sort_id'] == '::'), 2666 'inherit-columns' => isset($vars['inherit-columns']), 2667 'criteria' => $vars['criteria'] ?: array(), 2668 ); 2669 2670 if (!$setting['inherit-columns'] && $vars['columns']) { 2671 if (!$this->columns->updateColumns($vars['columns'], $errors, array( 2672 'queue_id' => $this->queue_id, 2673 'staff_id' => $this->staff_id))) 2674 $setting['inherit-columns'] = true; 2675 $this->columns->reset(); 2676 } 2677 2678 $this->setting = JsonDataEncoder::encode($setting); 2679 return $this->save(true); 2680 } 2681 2682 function save($refetch=false) { 2683 if ($this->dirty) 2684 $this->updated = SqlFunction::NOW(); 2685 return parent::save($refetch || $this->dirty); 2686 } 2687 2688 static function create($vars=false) { 2689 $inst = new static($vars); 2690 return $inst; 2691 } 2692} 2693 2694 2695class QueueExport 2696extends VerySimpleModel { 2697 static $meta = array( 2698 'table' => QUEUE_EXPORT_TABLE, 2699 'pk' => array('id'), 2700 'joins' => array( 2701 'queue' => array( 2702 'constraint' => array('queue_id' => 'CustomQueue.id'), 2703 ), 2704 ), 2705 'select_related' => array('queue'), 2706 'ordering' => array('sort'), 2707 ); 2708 2709 2710 function getPath() { 2711 return $this->path; 2712 } 2713 2714 function getField() { 2715 return $this->getPath(); 2716 } 2717 2718 function getHeading() { 2719 return $this->heading; 2720 } 2721 2722 static function create($vars=false) { 2723 $inst = new static($vars); 2724 return $inst; 2725 } 2726} 2727 2728class QueueColumnGlue 2729extends VerySimpleModel { 2730 static $meta = array( 2731 'table' => QUEUE_COLUMN_TABLE, 2732 'pk' => array('queue_id', 'staff_id', 'column_id'), 2733 'joins' => array( 2734 'column' => array( 2735 'constraint' => array('column_id' => 'QueueColumn.id'), 2736 ), 2737 'queue' => array( 2738 'constraint' => array( 2739 'queue_id' => 'CustomQueue.id', 2740 'staff_id' => 'CustomQueue.staff_id'), 2741 ), 2742 'config' => array( 2743 'constraint' => array( 2744 'queue_id' => 'QueueConfig.queue_id', 2745 'staff_id' => 'QueueConfig.staff_id'), 2746 ), 2747 ), 2748 'select_related' => array('column'), 2749 'ordering' => array('sort'), 2750 ); 2751} 2752 2753class QueueColumnGlueMIM 2754extends ModelInstanceManager { 2755 function getOrBuild($modelClass, $fields, $cache=true) { 2756 $m = parent::getOrBuild($modelClass, $fields, $cache); 2757 if ($m && $modelClass === 'QueueColumnGlue') { 2758 // Instead, yield the QueueColumn instance with the local fields 2759 // in the association table as annotations 2760 $m = AnnotatedModel::wrap($m->column, $m, 'QueueColumn'); 2761 } 2762 return $m; 2763 } 2764} 2765 2766class QueueColumnListBroker 2767extends InstrumentedList { 2768 function __construct($fkey, $queryset=false) { 2769 parent::__construct($fkey, $queryset, 'QueueColumnGlueMIM'); 2770 $this->queryset->select_related('column'); 2771 } 2772 2773 function add($column, $glue=null, $php7_is_annoying=true) { 2774 $glue = $glue ?: new QueueColumnGlue(); 2775 $glue->column = $column; 2776 $anno = AnnotatedModel::wrap($column, $glue); 2777 parent::add($anno, false); 2778 return $anno; 2779 } 2780 2781 function updateColumns($columns, &$errors, $options=array()) { 2782 $new = $columns; 2783 $order = array_keys($new); 2784 foreach ($this as $col) { 2785 $key = $col->column_id; 2786 if (!isset($columns[$key])) { 2787 $this->remove($col); 2788 continue; 2789 } 2790 $info = $columns[$key]; 2791 $col->set('sort', array_search($key, $order)); 2792 $col->set('heading', $info['heading']); 2793 $col->set('width', $info['width']); 2794 $col->setSortable($info['sortable']); 2795 unset($new[$key]); 2796 } 2797 // Add new columns 2798 foreach ($new as $info) { 2799 $glue = new QueueColumnGlue(array( 2800 'staff_id' => $options['staff_id'] ?: 0 , 2801 'queue_id' => $options['queue_id'] ?: 0, 2802 'column_id' => $info['column_id'], 2803 'sort' => array_search($info['column_id'], $order), 2804 'heading' => $info['heading'], 2805 'width' => $info['width'] ?: 100, 2806 'bits' => $info['sortable'] ? QueueColumn::FLAG_SORTABLE : 0, 2807 )); 2808 2809 $this->add(QueueColumn::lookup($info['column_id']), $glue); 2810 } 2811 // Re-sort the in-memory columns array 2812 $this->sort(function($c) { return $c->sort; }); 2813 2814 return $this->saveAll(); 2815 } 2816} 2817 2818class QueueSort 2819extends VerySimpleModel { 2820 static $meta = array( 2821 'table' => QUEUE_SORT_TABLE, 2822 'pk' => array('id'), 2823 'ordering' => array('name'), 2824 'joins' => array( 2825 'queue' => array( 2826 'constraint' => array('queue_id' => 'CustomQueue.id'), 2827 ), 2828 ), 2829 ); 2830 2831 var $_columns; 2832 var $_extra; 2833 2834 function getRoot($hint=false) { 2835 switch ($hint ?: $this->root) { 2836 case 'T': 2837 default: 2838 return 'Ticket'; 2839 } 2840 } 2841 2842 function getName() { 2843 return $this->name; 2844 } 2845 2846 function getId() { 2847 return $this->id; 2848 } 2849 2850 function getExtra() { 2851 if (isset($this->extra) && !isset($this->_extra)) 2852 $this->_extra = JsonDataParser::decode($this->extra); 2853 return $this->_extra; 2854 } 2855 2856 function applySort(QuerySet $query, $reverse=false, $root=false) { 2857 $fields = CustomQueue::getSearchableFields($this->getRoot($root)); 2858 foreach ($this->getColumnPaths() as $path=>$descending) { 2859 $descending = $reverse ? !$descending : $descending; 2860 if (isset($fields[$path])) { 2861 list(,$field) = $fields[$path]; 2862 $query = $field->applyOrderBy($query, $descending, 2863 CustomQueue::getOrmPath($path, $query)); 2864 } 2865 } 2866 // Add index hint if defined 2867 if (($extra = $this->getExtra()) && isset($extra['index'])) { 2868 $query->setOption(QuerySet::OPT_INDEX_HINT, $extra['index']); 2869 } 2870 return $query; 2871 } 2872 2873 function getColumnPaths() { 2874 if (!isset($this->_columns)) { 2875 $columns = array(); 2876 foreach (JsonDataParser::decode($this->columns) as $path) { 2877 if ($descending = $path[0] == '-') 2878 $path = substr($path, 1); 2879 $columns[$path] = $descending; 2880 } 2881 $this->_columns = $columns; 2882 } 2883 return $this->_columns; 2884 } 2885 2886 function getColumns() { 2887 $columns = array(); 2888 $paths = $this->getColumnPaths(); 2889 $everything = CustomQueue::getSearchableFields($this->getRoot()); 2890 foreach ($paths as $p=>$descending) { 2891 if (isset($everything[$p])) { 2892 $columns[$p] = array($everything[$p], $descending); 2893 } 2894 } 2895 return $columns; 2896 } 2897 2898 function getDataConfigForm($source=false) { 2899 return new QueueSortDataConfigForm($source ?: $this->getDbFields(), 2900 array('id' => $this->id)); 2901 } 2902 2903 function getAdvancedConfigForm($source=false) { 2904 return new QueueSortAdvancedConfigForm($source ?: $this->getExtra(), 2905 array('id' => $this->id)); 2906 } 2907 2908 static function forQueue(CustomQueue $queue) { 2909 return static::objects()->filter([ 2910 'root' => $queue->root ?: 'T', 2911 ]); 2912 } 2913 2914 function save($refetch=false) { 2915 if ($this->dirty) 2916 $this->updated = SqlFunction::NOW(); 2917 return parent::save($refetch || $this->dirty); 2918 } 2919 2920 function update($vars, &$errors=array()) { 2921 if (!isset($vars['name'])) 2922 $errors['name'] = __('A title is required'); 2923 2924 $this->name = $vars['name']; 2925 if (isset($vars['root'])) 2926 $this->root = $vars['root']; 2927 elseif (!isset($this->root)) 2928 $this->root = 'T'; 2929 2930 $fields = CustomQueue::getSearchableFields($this->getRoot($vars['root'])); 2931 $columns = array(); 2932 if (@is_array($vars['columns'])) { 2933 foreach ($vars['columns']as $path=>$info) { 2934 $descending = (int) @$info['descending']; 2935 // TODO: Check if column is valid, stash in $columns 2936 if (!isset($fields[$path])) 2937 continue; 2938 $columns[] = ($descending ? '-' : '') . $path; 2939 } 2940 $this->columns = JsonDataEncoder::encode($columns); 2941 } 2942 2943 if ($this->getExtra() !== null) { 2944 $extra = $this->getAdvancedConfigForm($vars)->getClean(); 2945 $this->extra = JsonDataEncoder::encode($extra); 2946 } 2947 2948 if (count($errors)) 2949 return false; 2950 2951 return $this->save(); 2952 } 2953 2954 static function __create($vars) { 2955 $c = new static($vars); 2956 $c->save(); 2957 return $c; 2958 } 2959} 2960 2961class QueueSortGlue 2962extends VerySimpleModel { 2963 static $meta = array( 2964 'table' => QUEUE_SORTING_TABLE, 2965 'pk' => array('sort_id', 'queue_id'), 2966 'joins' => array( 2967 'ordering' => array( 2968 'constraint' => array('sort_id' => 'QueueSort.id'), 2969 ), 2970 'queue' => array( 2971 'constraint' => array('queue_id' => 'CustomQueue.id'), 2972 ), 2973 ), 2974 'select_related' => array('ordering', 'queue'), 2975 'ordering' => array('sort'), 2976 ); 2977} 2978 2979class QueueSortGlueMIM 2980extends ModelInstanceManager { 2981 function getOrBuild($modelClass, $fields, $cache=true) { 2982 $m = parent::getOrBuild($modelClass, $fields, $cache); 2983 if ($m && $modelClass === 'QueueSortGlue') { 2984 // Instead, yield the QueueColumn instance with the local fields 2985 // in the association table as annotations 2986 $m = AnnotatedModel::wrap($m->ordering, $m, 'QueueSort'); 2987 } 2988 return $m; 2989 } 2990} 2991 2992class QueueSortListBroker 2993extends InstrumentedList { 2994 function __construct($fkey, $queryset=false) { 2995 parent::__construct($fkey, $queryset, 'QueueSortGlueMIM'); 2996 $this->queryset->select_related('ordering'); 2997 } 2998 2999 function add($ordering, $glue=null, $php7_is_annoying=true) { 3000 $glue = $glue ?: new QueueSortGlue(); 3001 $glue->ordering = $ordering; 3002 $anno = AnnotatedModel::wrap($ordering, $glue); 3003 parent::add($anno, false); 3004 return $anno; 3005 } 3006} 3007 3008abstract class QueueColumnFilter { 3009 static $registry; 3010 3011 static $id = null; 3012 static $desc = null; 3013 3014 static function register($filter, $group) { 3015 if (!isset($filter::$id)) 3016 throw new Exception('QueueColumnFilter must define $id'); 3017 if (isset(static::$registry[$filter::$id])) 3018 throw new Exception($filter::$id 3019 . ': QueueColumnFilter already registered under that id'); 3020 if (!is_subclass_of($filter, get_called_class())) 3021 throw new Exception('Filter must extend QueueColumnFilter'); 3022 3023 static::$registry[$filter::$id] = array($group, $filter); 3024 } 3025 3026 static function getFilters() { 3027 $list = static::$registry; 3028 $base = array(); 3029 foreach ($list as $id=>$stuff) { 3030 list($group, $class) = $stuff; 3031 $base[$group][$id] = __($class::$desc); 3032 } 3033 return $base; 3034 } 3035 3036 static function getInstance($id) { 3037 if (isset(static::$registry[$id])) { 3038 list(, $class) = @static::$registry[$id]; 3039 if ($class && class_exists($class)) 3040 return new $class(); 3041 } 3042 } 3043 3044 function mangleQuery($query, $column) { return $query; } 3045 3046 abstract function filter($value, $row); 3047} 3048 3049class TicketLinkFilter 3050extends QueueColumnFilter { 3051 static $id = 'link:ticket'; 3052 static $desc = /* @trans */ "Ticket Link"; 3053 3054 function filter($text, $row) { 3055 if ($link = $this->getLink($row)) 3056 return sprintf('<a style="display:inline" href="%s">%s</a>', $link, $text); 3057 } 3058 3059 function mangleQuery($query, $column) { 3060 static $fields = array( 3061 'link:ticket' => 'ticket_id', 3062 'link:ticketP' => 'ticket_id', 3063 'link:user' => 'user_id', 3064 'link:org' => 'user__org_id', 3065 ); 3066 3067 if (isset($fields[static::$id])) { 3068 $query = $query->values($fields[static::$id]); 3069 } 3070 return $query; 3071 } 3072 3073 function getLink($row) { 3074 return Ticket::getLink($row['ticket_id']); 3075 } 3076} 3077 3078class UserLinkFilter 3079extends TicketLinkFilter { 3080 static $id = 'link:user'; 3081 static $desc = /* @trans */ "User Link"; 3082 3083 function getLink($row) { 3084 return User::getLink($row['user_id']); 3085 } 3086} 3087 3088class OrgLinkFilter 3089extends TicketLinkFilter { 3090 static $id = 'link:org'; 3091 static $desc = /* @trans */ "Organization Link"; 3092 3093 function getLink($row) { 3094 return Organization::getLink($row['user__org_id']); 3095 } 3096} 3097QueueColumnFilter::register('TicketLinkFilter', __('Link')); 3098QueueColumnFilter::register('UserLinkFilter', __('Link')); 3099QueueColumnFilter::register('OrgLinkFilter', __('Link')); 3100 3101class TicketLinkWithPreviewFilter 3102extends TicketLinkFilter { 3103 static $id = 'link:ticketP'; 3104 static $desc = /* @trans */ "Ticket Link with Preview"; 3105 3106 function filter($text, $row) { 3107 $link = $this->getLink($row); 3108 return sprintf('<a style="display: inline" class="preview" data-preview="#tickets/%d/preview" href="%s">%s</a>', 3109 $row['ticket_id'], $link, $text); 3110 } 3111} 3112QueueColumnFilter::register('TicketLinkWithPreviewFilter', __('Link')); 3113 3114class DateTimeFilter 3115extends QueueColumnFilter { 3116 static $id = 'date:full'; 3117 static $desc = /* @trans */ "Date and Time"; 3118 3119 function filter($text, $row) { 3120 return $text ? 3121 $text->changeTo(Format::datetime($text->value)) : ''; 3122 } 3123} 3124 3125class HumanizedDateFilter 3126extends QueueColumnFilter { 3127 static $id = 'date:human'; 3128 static $desc = /* @trans */ "Relative Date and Time"; 3129 3130 function filter($text, $row) { 3131 return sprintf( 3132 '<time class="relative" datetime="%s" title="%s">%s</time>', 3133 date(DateTime::W3C, Misc::db2gmtime($text->value)), 3134 Format::daydatetime($text->value), 3135 Format::relativeTime(Misc::db2gmtime($text->value)) 3136 ); 3137 } 3138} 3139QueueColumnFilter::register('DateTimeFilter', __('Date Format')); 3140QueueColumnFilter::register('HumanizedDateFilter', __('Date Format')); 3141 3142class QueueColDataConfigForm 3143extends AbstractForm { 3144 function buildFields() { 3145 return array( 3146 'primary' => new DataSourceField(array( 3147 'label' => __('Primary Data Source'), 3148 'required' => true, 3149 'configuration' => array( 3150 'root' => 'Ticket', 3151 ), 3152 'layout' => new GridFluidCell(6), 3153 )), 3154 'secondary' => new DataSourceField(array( 3155 'label' => __('Secondary Data Source'), 3156 'configuration' => array( 3157 'root' => 'Ticket', 3158 ), 3159 'layout' => new GridFluidCell(6), 3160 )), 3161 'name' => new TextboxField(array( 3162 'label' => __('Name'), 3163 'required' => true, 3164 'layout' => new GridFluidCell(4), 3165 )), 3166 'filter' => new ChoiceField(array( 3167 'label' => __('Filter'), 3168 'required' => false, 3169 'choices' => QueueColumnFilter::getFilters(), 3170 'layout' => new GridFluidCell(4), 3171 )), 3172 'truncate' => new ChoiceField(array( 3173 'label' => __('Text Overflow'), 3174 'choices' => array( 3175 'wrap' => __("Wrap Lines"), 3176 'ellipsis' => __("Add Ellipsis"), 3177 'clip' => __("Clip Text"), 3178 'lclip' => __("Clip Beginning Text"), 3179 ), 3180 'default' => 'wrap', 3181 'layout' => new GridFluidCell(4), 3182 )), 3183 ); 3184 } 3185} 3186 3187class QueueSortDataConfigForm 3188extends AbstractForm { 3189 function getInstructions() { 3190 return __('Add, and remove the fields in this list using the options below. Sorting can be performed on any field, whether displayed in the queue or not.'); 3191 } 3192 3193 function buildFields() { 3194 return array( 3195 'name' => new TextboxField(array( 3196 'required' => true, 3197 'layout' => new GridFluidCell(12), 3198 'translatable' => isset($this->options['id']) 3199 ? _H('queuesort.name.'.$this->options['id']) : false, 3200 'configuration' => array( 3201 'placeholder' => __('Sort Criteria Title'), 3202 ), 3203 )), 3204 ); 3205 } 3206} 3207 3208class QueueSortAdvancedConfigForm 3209extends AbstractForm { 3210 function getInstructions() { 3211 return __('If unsure, leave these options blank and unset'); 3212 } 3213 3214 function buildFields() { 3215 return array( 3216 'index' => new TextboxField(array( 3217 'label' => __('Database Index'), 3218 'hint' => __('Use this index when sorting on this column'), 3219 'required' => false, 3220 'layout' => new GridFluidCell(12), 3221 'configuration' => array( 3222 'placeholder' => __('Automatic'), 3223 ), 3224 )), 3225 ); 3226 } 3227} 3228