Skip to content
Snippets Groups Projects
class.queue.php 70.8 KiB
Newer Older
<?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', 'default_sort'),
        'joins' => array(
            'children' => array(
                'reverse' => 'CustomQueue.parent',
                'constrain' => ['children__id__gt' => 0],
            ),
            'columns' => array(
                'reverse' => 'QueueColumnGlue.queue',
                'broker' => 'QueueColumnListBroker',
            'sorts' => array(
                'reverse' => 'QueueSortGlue.queue',
                'broker' => 'QueueSortListBroker',
            ),
            'default_sort' => array(
                'constraint' => array('sort_id' => 'QueueSort.id'),
                'null' => true,
            ),
            '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_DISABLED =         0x0004; // NOT enabled
    const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent
    const FLAG_INHERIT_COLUMNS =  0x0010; // Inherit column layout from parent
    const FLAG_INHERIT_SORTING =  0x0020; // Inherit advanced sorting from parent
    const FLAG_INHERIT_DEF_SORT = 0x0040; // Inherit default selected sort
    static function queues() {
        return parent::objects()->filter(array(
            'flags__hasbit' => static::FLAG_QUEUE
        ));
    }

    function __onload() {
        // Ensure valid state
        if ($this->hasFlag(self::FLAG_INHERIT_COLUMNS) && !$this->parent_id)
            $this->clearFlag(self::FLAG_INHERIT_COLUMNS);
    }

    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 ($path === ':keywords') {
                $items[] = Format::htmlchars("\"{$value}\"");
                continue;
            }
            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;

        // Early exit if already cached
        $fields = &$cache[$base];
        if ($fields)
            return $fields;

        if (!in_array('Searchable', class_implements($base)))
            return array();

        $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 (!is_array($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'])
            && ($kw = $criteria[':keywords'])
        ) {
            $items[] = array(':keywords', null, $kw);
    function getColumns($use_template=false) {
        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;
        // Use the columns of the "Open" queue as a default template
        if ($use_template && ($template = CustomQueue::lookup(1)))
            return $template->getColumns();

        // Last resort — use standard columns
        foreach (array(
            QueueColumn::placeholder(array(
                "heading" => "Number",
                "primary" => 'number',
                "width" => 85,
                "filter" => "link:ticketP",
                "annotations" => '[{"c":"TicketSourceDecoration","p":"b"}]',
                "conditions" => '[{"crit":["isanswered","set",null],"prop":{"font-weight":"bold"}}]',
            )),
            QueueColumn::placeholder(array(
                "heading" => "Created",
                "primary" => 'created',
                "width" => 100,
            )),
            QueueColumn::placeholder(array(
                "heading" => "Subject",
                "primary" => 'cdata__subject',
                "width" => 250,
                "filter" => "link:ticket",
                "annotations" => '[{"c":"TicketThreadCount","p":">"},{"c":"ThreadAttachmentCount","p":"a"},{"c":"OverdueFlagDecoration","p":"<"}]',
                "truncate" => 'ellipsis',
            )),
            QueueColumn::placeholder(array(
                "heading" => "From",
                "primary" => 'user__name',
                "width" => 150,
            )),
            QueueColumn::placeholder(array(
                "heading" => "Priority",
                "primary" => 'cdata__priority',
                "width" => 120,
            )),
            QueueColumn::placeholder(array(
                "heading" => "Assignee",
                "primary" => 'assignee',
                "width" => 100,
            )),
        ) as $col)
            $this->addColumn($col);

        return $this->getColumns();
    }

    function addColumn(QueueColumn $col) {
        $this->columns->add($col);
        $col->queue = $this;
    }

    function getSortOptions() {
        if ($this->inheritSorting() && $this->parent) {
            return $this->parent->getSortOptions();
        }
        return $this->sorts;
    }

    function getDefaultSortId() {
        if ($this->isDefaultSortInherited() && $this->parent
            && ($sort_id = $this->parent->getDefaultSortId())
        ) {
            return $sort_id;
        }
        return $this->sort_id;
    }

    function getDefaultSort() {
        if ($this->isDefaultSortInherited() && $this->parent
            && ($sort = $this->parent->getDefaultSort())
        ) {
            return $sort;
        }
        return $this->default_sort;
    }

    function getStatus() {
        return $this->hasFlag(self::FLAG_DISABLED)
            ? __('Disabled') : __('Active');
    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))
        ) {
            $filter = @self::getOrmPath($this->getQuickFilter(), $query);
            $query = $qf->applyQuickFilter($query, $quick_filter,
        }

        // Apply column, annotations and conditions additions
        foreach ($this->getColumns() as $C) {
            $query = $C->mangleQuery($query, $this->getRoot());
    function getQuickFilter() {
        if ($this->filter == '::' && $this->parent) {
            return $this->parent->getQuickFilter();
        }
        return $this->filter;
    }

    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($name => 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 applyDefaultSort($qs) {
        // Apply default sort
        if ($sorter = $this->getDefaultSort()) {
            $qs = $sorter->applySort($qs, false, $this->getRoot());
        }
        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 inheritSorting() {
        return $this->hasFlag(self::FLAG_INHERIT_SORTING);
    }

    function isDefaultSortInherited() {
        return $this->hasFlag(self::FLAG_INHERIT_DEF_SORT);
    }

    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 disable() {
        $this->setFlag(self::FLAG_DISABLED);
    }

    function enable() {
        $this->clearFlag(self::FLAG_DISABLED);
    }
    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_id && !$this->parent)
            $errors['parent_id'] = __('Select a valid queue');
        // Configure quick filter options
        $this->filter = $vars['filter'];
        if ($vars['sort_id']) {
            if ($vars['filter'] === '::') {
                if (!$this->parent)
                    $errors['filter'] = __('No parent selected');
            }
            elseif ($vars['filter'] && !array_key_exists($vars['filter'],
                static::getSearchableFields($this->getRoot()))
            ) {
                $errors['filter'] = __('Select an item from the list');
            }
        }

        // Set basic queue information
        $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']));
        $this->setFlag(self::FLAG_INHERIT_SORTING,
            $this->parent_id > 0 && isset($vars['inherit-sorting']));

        // Update queue columns (but without save)
        if (!isset($vars['columns']) && $this->parent) {
            // No columns -- imply column inheritance
            $this->setFlag(self::FLAG_INHERIT_COLUMNS);
        }
        if (isset($vars['columns']) && !$this->hasFlag(self::FLAG_INHERIT_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; });
        }
        // Update advanced sorting options for the queue
        if (isset($vars['sorts']) && !$this->hasFlag(self::FLAG_INHERIT_SORTING)) {
            $new = $order = $vars['sorts'];
            foreach ($this->sorts as $sort) {
                $key = $sort->sort_id;
                $idx = array_search($key, $vars['sorts']);
                if (false === $idx) {
                    $this->sorts->remove($sort);
                }
                else {
                    $sort->set('sort', $idx);
                    unset($new[$idx]);
                }
            }
            // Add new columns
            foreach ($new as $id) {
                if (!$sort = QueueSort::lookup($id))
                    continue;
                $glue = new QueueSortGlue(array(
                    'sort_id' => $id,
                    'queue' => $this,
                    'sort' => array_search($id, $order),
                ));
                $this->sorts->add($sort, $glue);
            }
            // Re-sort the in-memory columns array
            $this->sorts->sort(function($c) { return $c->sort; });
        }
        if (!count($this->sorts) && $this->parent) {
            // No sorting -- imply sorting inheritance
            $this->setFlag(self::FLAG_INHERIT_SORTING);
        }

        // Configure default sorting
        $this->setFlag(self::FLAG_INHERIT_DEF_SORT,
            $this->parent && $vars['sort_id'] === '::');
        if ($vars['sort_id']) {
            if ($vars['sort_id'] === '::') {
                if (!$this->parent)
                    $errors['sort_id'] = __('No parent selected');
            }
            elseif ($qs = QueueSort::lookup($vars['sort_id'])) {
                $this->sort_id = $vars['sort_id'];
            }
            else {
                $errors['sort_id'] = __('Select an item from the list');
            }
        }
        // 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()
            && $this->sorts->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();
        }
        if (isset($vars['sorts'])) {
            foreach ($vars['sorts'] as $info) {
                $glue = new QueueSortGlue($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) {