Skip to content
Snippets Groups Projects
class.queue.php 58.7 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?php
    /*********************************************************************
        class.queue.php
    
        Custom (ticket) queues for osTicket
    
        Jared Hancock <jared@osticket.com>
        Peter Rotich <peter@osticket.com>
    
        Copyright (c)  2006-2015 osTicket
    
        http://www.osticket.com
    
        Released under the GNU General Public License WITHOUT ANY WARRANTY.
        See LICENSE.TXT for details.
    
        vim: expandtab sw=4 ts=4 sts=4:
    **********************************************************************/
    
    
    class CustomQueue extends VerySimpleModel {
    
        static $meta = array(
    
            'table' => QUEUE_TABLE,
            'pk' => array('id'),
            'ordering' => array('sort'),
    
            'select_related' => array('parent'),
    
            'joins' => array(
    
                'children' => array(
                    'reverse' => 'CustomQueue.parent',
                    'constrain' => ['children__id__gt' => 0],
                ),
    
                'columns' => array(
    
                    'reverse' => 'QueueColumnGlue.queue',
                    'broker' => 'QueueColumnListBroker',
    
                'parent' => array(
                    'constraint' => array(
                        'parent_id' => 'CustomQueue.id',
                    ),
                    'null' => true,
                ),
                'staff' => array(
                    'constraint' => array(
                        'staff_id' => 'Staff.staff_id',
                    )
    
        const FLAG_PUBLIC =         0x0001; // Shows up in e'eryone's saved searches
        const FLAG_QUEUE =          0x0002; // Shows up in queue navigation
        const FLAG_CONTAINER =      0x0004; // Container for other queues ('Open')
        const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent
        const FLAG_INHERIT_COLUMNS = 0x0010; // Inherit column layout from parent
    
        var $criteria;
    
    
        static function queues() {
    
            return parent::objects()->filter(array(
                'flags__hasbit' => static::FLAG_QUEUE
            ));
        }
    
    
        function getId() {
            return $this->id;
        }
    
        function getName() {
            return $this->title;
        }
    
        function getHref() {
            // TODO: Get base page from getRoot();
            $root = $this->getRoot();
            return 'tickets.php?queue='.$this->getId();
        }
    
        function getRoot() {
            switch ($this->root) {
            case 'T':
            default:
                return 'Ticket';
            }
        }
    
        function getPath() {
            return $this->path ?: $this->buildPath();
        }
    
        function getCriteria($include_parent=false) {
            if (!isset($this->criteria)) {
                $old = @$this->config[0] === '{';
                $this->criteria = is_string($this->config)
                    ? JsonDataParser::decode($this->config)
                    : $this->config;
                // Auto-upgrade criteria to new format
                if ($old) {
                    // TODO: Upgrade old ORM path names
                    $this->criteria = $this->isolateCriteria($this->criteria);
                }
            }
            $criteria = $this->criteria ?: array();
            if ($include_parent && $this->parent_id && $this->parent) {
                $criteria = array_merge($this->parent->getCriteria(true),
                    $criteria);
            }
            return $criteria;
        }
    
        function describeCriteria($criteria=false){
            $all = $this->getSupportedMatches($this->getRoot());
            $items = array();
            $criteria = $criteria ?: $this->getCriteria(true);
            foreach ($criteria as $C) {
                list($path, $method, $value) = $C;
                if (!isset($all[$path]))
                    continue;
                 list($label, $field) = $all[$path];
                 $items[] = $field->describeSearch($method, $value, $label);
            }
            return implode("\nAND ", $items);
        }
    
        /**
         * Fetch an AdvancedSearchForm instance for use in displaying or
         * configuring this search in the user interface.
         *
         * Parameters:
         * $search - <array> Request parameters ($_POST) used to update the
         *      search beyond the current configuration of the search criteria
         */
        function getForm($source=null) {
            $searchable = $this->getCurrentSearchFields($source);
            $fields = array(
                ':keywords' => new TextboxField(array(
                    'id' => 3001,
                    'configuration' => array(
                        'size' => 40,
                        'length' => 400,
                        'autofocus' => true,
                        'classes' => 'full-width headline',
                        'placeholder' => __('Keywords — Optional'),
                    ),
                )),
            );
            foreach ($searchable as $path=>$field) {
                $fields = array_merge($fields, static::getSearchField($field, $path));
            }
    
            $form = new AdvancedSearchForm($fields, $source);
            $form->addValidator(function($form) {
                $selected = 0;
                foreach ($form->getFields() as $F) {
                    if (substr($F->get('name'), -7) == '+search' && $F->getClean())
                        $selected += 1;
                    // Consider keyword searches
                    elseif ($F->get('name') == ':keywords' && $F->getClean())
                        $selected += 1;
                }
                if (!$selected)
                    $form->addError(__('No fields selected for searching'));
            });
    
            // Load state from current configuraiton
            if (!$source) {
                foreach ($this->getCriteria() as $I) {
                    list($path, $method, $value) = $I;
                    if ($path == ':keywords' && $method === null) {
                        if ($F = $form->getField($path))
                            $F->value = $value;
                        continue;
                    }
    
                    if (!($F = $form->getField("{$path}+search")))
                        continue;
                    $F->value = true;
    
                    if (!($F = $form->getField("{$path}+method")))
                        continue;
                    $F->value = $method;
    
                    if ($value && ($F = $form->getField("{$path}+{$method}")))
                        $F->value = $value;
                }
            }
            return $form;
        }
    
        /**
         * Fetch a bucket of fields for a custom search. The fields should be
         * added to a form before display. One searchable field may encompass 10
         * or more actual fields because fields are expanded to support multiple
         * search methods along with the fields for each search method. This
         * method returns all the FormField instances for all the searchable
         * model fields currently in use.
         *
         * Parameters:
         * $source - <array> data from a request. $source['fields'] is expected
         *      to contain a list extra fields by ORM path, of newly added
         *      fields not yet saved in this object's getCriteria().
         */
        function getCurrentSearchFields($source=array()) {
            static $basic = array(
                'Ticket' => array(
                    'status__state',
                    'dept_id',
                    'assignee',
                    'topic_id',
                    'created',
                    'est_duedate',
                )
            );
    
            $all = $this->getSupportedMatches();
            $core = array();
    
            // Include basic fields for new searches
            if (!isset($this->id))
                foreach ($basic[$this->getRoot()] as $path)
                    if (isset($all[$path]))
                        $core[$path] = $all[$path];
    
            // Add others from current configuration
            foreach ($this->getCriteria() as $C) {
                list($path) = $C;
                if (isset($all[$path]))
                    $core[$path] = $all[$path];
            }
    
            if (isset($source['fields']))
                foreach ($source['fields'] as $path)
                    if (isset($all[$path]))
                        $core[$path] = $all[$path];
    
            return $core;
        }
    
        /**
         * Fetch all supported ORM fields searchable by this search object. The
         * returned list represents searchable fields, keyed by the ORM path.
         * Use ::getCurrentSearchFields() or ::getSearchField() to retrieve for
         * use in the user interface.
         */
        function getSupportedMatches() {
            return static::getSearchableFields($this->getRoot());
        }
    
        /**
         * Trace ORM fields from a base object and retrieve a complete list of
         * fields which can be used in an ORM query based on the base object.
         * The base object must implement Searchable interface and extend from
         * VerySimpleModel. Then all joins from the object are also inspected,
         * and any which implement the Searchable interface are traversed and
         * automatically added to the list. The resulting list is cached based
         * on the $base class, so multiple calls for the same $base return
         * quickly.
         *
         * Parameters:
         * $base - Class, name of a class implementing Searchable
         * $recurse - int, number of levels to recurse, default is 2
         * $cache - bool, cache results for future class for the same base
         * $customData - bool, include all custom data fields for all general
         *      forms
         */
        static function getSearchableFields($base, $recurse=2,
            $customData=true, $exclude=array()
        ) {
            static $cache = array(), $otherFields;
    
            if (!in_array('Searchable', class_implements($base)))
                return array();
    
            // Early exit if already cached
            $fields = &$cache[$base];
            if ($fields)
                return $fields;
    
            $fields = $fields ?: array();
            foreach ($base::getSearchableFields() as $path=>$F) {
                if (is_array($F)) {
                    list($label, $field) = $F;
                }
                else {
                    $label = $F->get('label');
                    $field = $F;
                }
                $fields[$path] = array($label, $field);
            }
    
            if ($customData && $base::supportsCustomData()) {
                if (!isset($otherFields)) {
                    $otherFields = array();
                    $dfs = DynamicFormField::objects()
                        ->filter(array('form__type' => 'G'))
                        ->select_related('form');
                    foreach ($dfs as $field) {
                        $otherFields[$field->getId()] = array($field->form,
                            $field->getImpl());
                    }
                }
                foreach ($otherFields as $id=>$F) {
                    list($form, $field) = $F;
                    $label = sprintf("%s / %s",
                        $form->getTitle(), $field->get('label'));
                    $fields["entries__answers!{$id}__value"] = array(
                        $label, $field);
                }
            }
    
            if ($recurse) {
                $exclude[$base] = 1;
                foreach ($base::getMeta('joins') as $path=>$j) {
                    $fc = $j['fkey'][0];
                    if (isset($exclude[$fc]) || $j['list'])
                        continue;
                    foreach (static::getSearchableFields($fc, $recurse-1,
                        true, $exclude)
                    as $path2=>$F) {
                        list($label, $field) = $F;
                        $fields["{$path}__{$path2}"] = array(
                            sprintf("%s / %s", $fc, $label),
                            $field);
                    }
                }
            }
    
            return $fields;
        }
    
        /**
         * Fetch the FormField instances used when for configuring a searchable
         * field in the user interface. This is the glue between a field
         * representing a searchable model field and the configuration of that
         * search in the user interface.
         *
         * Parameters:
         * $F - <array<string, FormField>> the label and the FormField instance
         *      representing the configurable search
         * $name - <string> ORM path for the search
         */
        static function getSearchField($F, $name) {
            list($label, $field) = $F;
    
            $pieces = array();
            $pieces["{$name}+search"] = new BooleanField(array(
                'id' => sprintf('%u', crc32($name)) >> 1,
                'configuration' => array(
                    'desc' => $label ?: $field->getLocal('label'),
                    'classes' => 'inline',
                ),
            ));
            $methods = $field->getSearchMethods();
            $pieces["{$name}+method"] = new ChoiceField(array(
                'choices' => $methods,
                'default' => key($methods),
                'visibility' => new VisibilityConstraint(new Q(array(
                    "{$name}+search__eq" => true,
                )), VisibilityConstraint::HIDDEN),
            ));
            $offs = 0;
            foreach ($field->getSearchMethodWidgets() as $m=>$w) {
                if (!$w)
                    continue;
                list($class, $args) = $w;
                $args['required'] = true;
                $args['__searchval__'] = true;
                $args['visibility'] = new VisibilityConstraint(new Q(array(
                        "{$name}+method__eq" => $m,
                    )), VisibilityConstraint::HIDDEN);
                $pieces["{$name}+{$m}"] = new $class($args);
            }
            return $pieces;
        }
    
        function getField($path) {
            $searchable = $this->getSupportedMatches();
            return $searchable[$path];
        }
    
        // Remove this and adjust advanced-search-criteria template to use the
        // getCriteria() list and getField()
        function getSearchFields($form=false) {
            $form = $form ?: $this->getForm();
            $searchable = $this->getCurrentSearchFields();
            $info = array();
            foreach ($form->getFields() as $f) {
                if (substr($f->get('name'), -7) == '+search') {
                    $name = substr($f->get('name'), 0, -7);
                    $value = null;
                    // Determine the search method and fetch the original field
                    if (($M = $form->getField("{$name}+method"))
                        && ($method = $M->getClean())
                        && (list(,$field) = $searchable[$name])
                    ) {
                        // Request the field to generate a search Q for the
                        // search method and given value
                        if ($value = $form->getField("{$name}+{$method}"))
                            $value = $value->getClean();
                    }
                    $info[$name] = array(
                        'field' => $field,
                        'method' => $method,
                        'value' => $value,
                        'active' =>  $f->getClean(),
                    );
                }
            }
            return $info;
        }
    
        /**
         * Take the criteria from the SavedSearch fields setup and isolate the
         * field name being search, the method used for searhing, and the method-
         * specific data entered in the UI.
         */
        function isolateCriteria($criteria, $root=null) {
            $searchable = static::getSearchableFields($root ?: $this->getRoot());
            $items = array();
            if (!$criteria)
                return null;
            foreach ($criteria as $k=>$v) {
                if (substr($k, -7) === '+method') {
                    list($name,) = explode('+', $k, 2);
                    if (!isset($searchable[$name]))
                        continue;
    
                    // Require checkbox to be checked too
                    if (!$criteria["{$name}+search"])
                        continue;
    
                    // Lookup the field to search this condition
                    list($label, $field) = $searchable[$name];
    
                    // Get the search method and value
                    $method = $v;
                    // Not all search methods require a value
                    $value = $criteria["{$name}+{$method}"];
    
                    $items[] = array($name, $method, $value);
                }
            }
            if (isset($criteria[':keywords'])) {
                $items[] = array(':keywords', null, $criteria[':keywords']);
            }
            return $items;
        }
    
    
        function getColumns() {
    
            if ($this->columns_id
                && ($q = CustomQueue::lookup($this->columns_id))
            ) {
                // Use columns from cited queue
                return $q->getColumns();
            }
            elseif ($this->parent_id
    
                && $this->hasFlag(self::FLAG_INHERIT_COLUMNS)
                && $this->parent
            ) {
                return $this->parent->getColumns();
            }
    
            elseif (count($this->columns)) {
                return $this->columns;
    
    
            // Last resort — use standard columns
            return array(
                new QueueColumn(array(
                    "heading" => "Number",
                    "primary" => 'number',
                    "width" => 85,
                    "filter" => "link:ticketP",
                    "annotations" => '[{"c":"TicketSourceDecoration","p":"b"}]',
                    "conditions" => '[{"crit":["isanswered","set",null],"prop":{"font-weight":"bold"}}]',
                )),
                new QueueColumn(array(
                    "heading" => "Created",
                    "primary" => 'created',
                    "width" => 100,
                )),
                new QueueColumn(array(
                    "heading" => "Subject",
                    "primary" => 'cdata__subject',
                    "width" => 250,
                    "filter" => "link:ticket",
                    "annotations" => '[{"c":"TicketThreadCount","p":">"},{"c":"ThreadAttachmentCount","p":"a"},{"c":"OverdueFlagDecoration","p":"<"}]',
                    "truncate" => 'ellipsis',
                )),
                new QueueColumn(array(
                    "heading" => "From",
                    "primary" => 'user__name',
                    "width" => 150,
                )),
                new QueueColumn(array(
                    "heading" => "Priority",
                    "primary" => 'cdata__priority',
                    "width" => 120,
                )),
                new QueueColumn(array(
                    "heading" => "Assignee",
                    "primary" => 'assignee',
                    "width" => 100,
                )),
            );
    
        }
    
        function addColumn(QueueColumn $col) {
            $this->columns->add($col);
            $col->queue = $this;
        }
    
    
        function getStatus() {
            return 'bogus';
        }
    
    
        function getChildren() {
            return $this->children;
        }
    
        function getPublicChildren() {
            return $this->children->findAll(array(
    
                'flags__hasbit' => self::FLAG_QUEUE
    
            ));
        }
    
        function getMyChildren() {
            global $thisstaff;
            if (!$thisstaff instanceof Staff)
                return array();
    
            return $this->children->findAll(array(
                'staff_id' => $thisstaff->getId(),
                Q::not(array(
                    'flags__hasbit' => self::FLAG_PUBLIC
                ))
            ));
        }
    
    
        /**
         * Add critiera to a query based on the constraints configured for this
         * queue. The criteria of the parent queue is also automatically added
         * if the queue is configured to inherit the criteria.
         */
        function getBasicQuery() {
    
            if ($this->parent && $this->inheritCriteria()) {
                $query = $this->parent->getBasicQuery();
            }
            else {
                $root = $this->getRoot();
                $query = $root::objects();
            }
    
            return $this->mangleQuerySet($query);
    
        }
    
        /**
         * Retrieve a QuerySet instance based on the type of object (root) of
         * this Q, which is automatically configured with the data and criteria
         * of the queue and its columns.
         *
         * Returns:
         * <QuerySet> instance
         */
    
        function getQuery($form=false, $quick_filter=null) {
    
            // Start with basic criteria
    
            $query = $this->getBasicQuery($form);
    
    
            // Apply quick filter
            if (isset($quick_filter)
                && ($qf = $this->getQuickFilterField($quick_filter))
            ) {
    
                $this->filter = @self::getOrmPath($this->filter, $query);
    
                $query = $qf->applyQuickFilter($query, $quick_filter,
                    $this->filter); 
            }
    
            // Apply column, annotations and conditions additions
    
            foreach ($this->getColumns() as $C) {
    
                $query = $C->mangleQuery($query, $this->getRoot());
    
        function getQuickFilterField($value=null) {
    
            if ($this->filter == '::') {
                if ($this->parent) {
                    return $this->parent->getQuickFilterField($value);
                }
            }
            elseif ($this->filter
    
                && ($fields = self::getSearchableFields($this->getRoot()))
    
                && (list(,$f) = @$fields[$this->filter])
    
                && $f->supportsQuickFilter()
            ) {
                $f->value = $value;
                return $f;
            }
        }
    
    
        /**
         * Get a description of a field in a search. Expects an entry from the
         * array retrieved in ::getSearchFields()
         */
        function describeField($info, $name=false) {
            return $info['field']->describeSearch($info['method'], $info['value'], $name);
        }
    
        function mangleQuerySet(QuerySet $qs, $form=false) {
            $qs = clone $qs;
            $searchable = $this->getSupportedMatches();
    
            // Figure out fields to search on
            foreach ($this->getCriteria() as $I) {
                list($name, $method, $value) = $I;
    
                // Consider keyword searching
                if ($name === ':keywords') {
                    global $ost;
                    $qs = $ost->searcher->find($value, $qs, false);
                }
                else {
                    // XXX: Move getOrmPath to be more of a utility
                    // Ensure the special join is created to support custom data joins
                    $name = @static::getOrmPath($name, $qs);
    
                    if (preg_match('/__answers!\d+__/', $name)) {
                        $qs->annotate(array($name2 => SqlAggregate::MAX($name)));
                    }
    
                    // Fetch a criteria Q for the query
                    if (list(,$field) = $searchable[$name])
                        if ($q = $field->getSearchQ($method, $value, $name))
                            $qs = $qs->filter($q);
                }
            }
            return $qs;
        }
    
        function checkAccess(Staff $agent) {
            return $agent->getId() == $this->staff_id
                || $this->hasFlag(self::FLAG_PUBLIC);
        }
    
        function ignoreVisibilityConstraints() {
            global $thisstaff;
    
            // For saved searches (not queues), staff can have a permission to
            // see all records
            return !$this->isAQueue()
                && $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING);
        }
    
        function inheritCriteria() {
            return $this->flags & self::FLAG_INHERIT_CRITERIA;
        }
    
        function inheritColumns() {
            return $this->hasFlag(self::FLAG_INHERIT_COLUMNS);
        }
    
        function buildPath() {
            if (!$this->id)
                return;
    
            $path = $this->parent ? $this->parent->getPath() : '';
            return $path . "/{$this->id}";
        }
    
        function getFullName() {
            $base = $this->getName();
            if ($this->parent)
                $base = sprintf("%s / %s", $this->parent->getFullName(), $base);
            return $base;
        }
    
        function isAQueue() {
            return $this->hasFlag(self::FLAG_QUEUE);
        }
    
        function isPrivate() {
            return !$this->isAQueue() && !$this->hasFlag(self::FLAG_PUBLIC);
        }
    
        protected function hasFlag($flag) {
            return ($this->flags & $flag) !== 0;
        }
    
        protected function clearFlag($flag) {
            return $this->flags &= ~$flag;
        }
    
        protected function setFlag($flag, $value=true) {
            return $value
                ? $this->flags |= $flag
                : $this->clearFlag($flag);
        }
    
    
    
        function update($vars, &$errors=array()) {
    
            // Set basic search information
            if (!$vars['name'])
                $errors['name'] = __('A title is required');
    
            $this->title = $vars['name'];
            $this->parent_id = @$vars['parent_id'] ?: 0;
            if (!$this->parent)
                $errors['parent_id'] = __('Select a valid queue');
    
    
            // Set basic queue information
    
            $this->filter = $vars['filter'];
    
            $this->path = $this->buildPath();
    
            $this->setFlag(self::FLAG_INHERIT_CRITERIA,
                $this->parent_id > 0 && isset($vars['inherit']));
    
            $this->setFlag(self::FLAG_INHERIT_COLUMNS,
                $this->parent_id > 0 && isset($vars['inherit-columns']));
    
    
            // Update queue columns (but without save)
            if (isset($vars['columns'])) {
    
                $new = $vars['columns'];
    
                $order = array_keys($new);
    
                foreach ($this->columns as $col) {
    
                    $key = $col->column_id;
                    if (!isset($vars['columns'][$key])) {
    
                        $this->columns->remove($col);
                        continue;
    
                    $info = $vars['columns'][$key];
                    $col->set('sort', array_search($key, $order));
                    $col->set('heading', $info['heading']);
                    $col->set('width', $info['width']);
    
                    $col->setSortable($info['sortable']);
    
                    unset($new[$key]);
    
                }
                // Add new columns
    
                foreach ($new as $info) {
    
                    $glue = new QueueColumnGlue(array(
    
                        'column_id' => $info['column_id'], 
                        'sort' => array_search($info['column_id'], $order),
                        'heading' => $info['heading'],
    
                        'width' => $info['width'] ?: 100,
                        'bits' => $info['sortable'] ?  QueueColumn::FLAG_SORTABLE : 0,
    
                    ));
                    $glue->queue = $this;
                    $this->columns->add(
                        QueueColumn::lookup($info['column_id']), $glue);
    
                }
                // Re-sort the in-memory columns array
                $this->columns->sort(function($c) { return $c->sort; });
            }
    
            else {
                // No columns -- imply column inheritance
                $this->setFlag(self::FLAG_INHERIT_COLUMNS);
            }
    
    
            // TODO: Move this to SavedSearch::update() and adjust
            //       AjaxSearch::_saveSearch()
            $form = $form ?: $this->getForm($vars);
            if (!$vars || !$form->isValid()) {
                $errors['criteria'] = __('Validation errors exist on criteria');
            }
            else {
                $this->config = JsonDataEncoder::encode(
                    $this->isolateCriteria($form->getClean()));
            }
    
    
            return 0 === count($errors);
        }
    
        function save($refetch=false) {
    
            $wasnew = !isset($this->id);
    
    
            if ($this->dirty)
                $this->updated = SqlFunction::NOW();
            if (!($rv = parent::save($refetch || $this->dirty)))
    
            if ($wasnew) {
                $this->path = $this->buildPath();
                $this->save();
            }
    
            return $this->columns->saveAll();
        }
    
    
        static function getOrmPath($name, $query=null) {
            // Special case for custom data `__answers!id__value`. Only add the
            // join and constraint on the query the first pass, when the query
            // being mangled is received.
            $path = array();
            if ($query && preg_match('/^(.+?)__(answers!(\d+))/', $name, $path)) {
                // Add a join to the model of the queryset where the custom data
                // is forked from — duplicate the 'answers' join and add the
                // constraint to the query based on the field_id
                // $path[1] - part before the answers (user__org__entries)
                // $path[2] - answers!xx join part
                // $path[3] - the `xx` part of the answers!xx join component
                $root = $query->model;
                $meta = $root::getMeta()->getByPath($path[1]);
                $joins = $meta['joins'];
                if (!isset($joins[$path[2]])) {
                    $meta->addJoin($path[2], $joins['answers']);
                }
                // Ensure that the query join through answers!xx is only for the
                // records which match field_id=xx
                $query->constrain(array("{$path[1]}__{$path[2]}" =>
                    array("{$path[1]}__{$path[2]}__field_id" => (int) $path[3])
                ));
                // Leave $name unchanged
            }
            return $name;
        }
    
    
    
        static function create($vars=false) {
            global $thisstaff;
    
    
            $queue = new static($vars);
            $queue->created = SqlFunction::NOW();
            $queue->setFlag(self::FLAG_QUEUE);
    
            if ($thisstaff)
                $queue->staff_id = $thisstaff->getId();
    
            return $queue;
        }
    
    
        static function __create($vars) {
            $q = static::create($vars);
            $q->save();
    
            foreach ($vars['columns'] as $info) {
    
                $glue = new QueueColumnGlue($info);
    
                $glue->queue_id = $q->getId();
                $glue->save();
            }
    
            return $q;
        }
    
    abstract class QueueColumnAnnotation {
    
        static $icon = false;
        static $desc = '';
    
        var $config;
    
        function __construct($config) {
            $this->config = $config;
        }
    
        static function fromJson($config) {
            $class = $config['c'];
            if (class_exists($class))
                return new $class($config);
        }
    
        static function getDescription() {
            return __(static::$desc);
        }
        static function getIcon() {
            return static::$icon;
        }
        static function getPositions() {
            return array(
                "<" => __('Start'),
                "b" => __('Before'),
                "a" => __('After'),
                ">" => __('End'),
            );
        }
    
        function decorate($text, $dec) {
            static $positions = array(
                '<' => '<span class="pull-left">%2$s</span>%1$s',
                '>' => '<span class="pull-right">%2$s</span>%1$s',
    
                'a' => '%1$s%2$s',
                'b' => '%2$s%1$s',
    
            $pos = $this->getPosition();
    
            if (!isset($positions[$pos]))
                return $text;
    
            return sprintf($positions[$pos], $text, $dec);
        }
    
        // Render the annotation with the database record $row. $text is the
    
        // text of the cell before annotations were applied.
    
        function render($row, $cell) {
            if ($decoration = $this->getDecoration($row, $cell))
                return $this->decorate($cell, $decoration);
    
            return $cell;
        }
    
        // Add the annotation to a QuerySet
        abstract function annotate($query);
    
        // Fetch some HTML to render the decoration on the page. This function
        // can return boolean FALSE to indicate no decoration should be applied
        abstract function getDecoration($row, $text);
    
        function getPosition() {
            return strtolower($this->config['p']) ?: 'a';
        }
    
    
        function getClassName() {
            return @$this->config['c'] ?: get_class();
        }
    
    
        static function getAnnotations($root) {
            // Ticket annotations
            static $annotations;
            if (!isset($annotations[$root])) {
                foreach (get_declared_classes() as $class)
                    if (is_subclass_of($class, get_called_class()))
                        $annotations[$root][] = $class;
            }
            return $annotations[$root];
        }
    
    
        /**
         * Estimate the width of the rendered annotation in pixels
         */
    
        function getWidth($row) {
            return $this->isVisible($row) ? 25 : 0;
        }
    
        function isVisible($row) {
            return true;
    
    }
    
    class TicketThreadCount
    
    extends QueueColumnAnnotation {
    
        static $icon = 'comments-alt';
        static $qname = '_thread_count';
        static $desc = /* @trans */ 'Thread Count';
    
        function annotate($query) {
            return $query->annotate(array(
            static::$qname => TicketThread::objects()
                ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1)))
                ->exclude(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN))
                ->aggregate(array('count' => SqlAggregate::COUNT('entries__id')))
            ));
        }
    
        function getDecoration($row, $text) {
            $threadcount = $row[static::$qname];
            if ($threadcount > 1) {
                return sprintf(
    
                    '<small class="faded-more"><i class="icon-comments-alt"></i> %s</small>',
    
    
        function isVisible($row) {
            return $row[static::$qname] > 1;
        }
    
    }
    
    class ThreadAttachmentCount
    
    extends QueueColumnAnnotation {
    
        static $icon = 'paperclip';
        static $qname = '_att_count';
        static $desc = /* @trans */ 'Attachment Count';
    
        function annotate($query) {
            // TODO: Convert to Thread attachments
            return $query->annotate(array(
            static::$qname => TicketThread::objects()
                ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1)))
                ->filter(array('entries__attachments__inline' => 0))
                ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id')))
            ));
        }
    
        function getDecoration($row, $text) {
            $count = $row[static::$qname];
            if ($count) {
                return sprintf(
                    '<i class="small icon-paperclip icon-flip-horizontal" data-toggle="tooltip" title="%s"></i>',
                    $count);
            }
        }
    
    
        function isVisible($row) {
            return $row[static::$qname] > 0;
        }
    
    class ThreadCollaboratorCount
    extends QueueColumnAnnotation {
        static $icon = 'group';
        static $qname = '_collabs';
        static $desc = /* @trans */ 'Collaborator Count';
    
        function annotate($query) {
            return $query->annotate(array(
            static::$qname => TicketThread::objects()
                ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1)))
                ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id')))
            ));
        }
    
        function getDecoration($row, $text) {
            $count = $row[static::$qname];