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                '&nbsp;<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