diff --git a/include/class.orm.php b/include/class.orm.php index 5b047ab05629e387d9fdfcbea5ccf7b7684d3012..fe5d6e2781b0efd07779578b1bab208665168dc5 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -15,6 +15,7 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +require_once INCLUDE_DIR . 'class.util.php'; class OrmException extends Exception {} class OrmConfigurationException extends Exception {} @@ -723,11 +724,11 @@ class AnnotatedModel { $classes[$class] = eval(<<<END_CLASS class {$extra}AnnotatedModel___{$class} extends {$class} { - var \$__overlay__; + protected \$__overlay__; use {$extra}AnnotatedModelTrait; function __construct(\$ht, \$annotations) { - parent::__construct(\$ht); + \$this->ht = \$ht; \$this->__overlay__ = \$annotations; } } @@ -779,7 +780,8 @@ trait WriteableAnnotatedModelTrait { } function set($what, $to) { - if ($this->__overlay__->__isset($what)) { + if (isset($this->__overlay__) + && $this->__overlay__->__isset($what)) { return $this->__overlay__->set($what, $to); } return parent::set($what, $to); @@ -1558,54 +1560,54 @@ class DoesNotExist extends Exception {} class ObjectNotUnique extends Exception {} class CachedResultSet -implements IteratorAggregate, Countable, ArrayAccess { +extends BaseList +implements ArrayAccess { protected $inner; protected $eoi = false; - protected $cache = array(); function __construct(IteratorAggregate $iterator) { $this->inner = $iterator->getIterator(); } function fillTo($level) { - while (!$this->eoi && count($this->cache) < $level) { + while (!$this->eoi && count($this->storage) < $level) { if (!$this->inner->valid()) { $this->eoi = true; break; } - $this->cache[] = $this->inner->current(); + $this->storage[] = $this->inner->current(); $this->inner->next(); } } - function reset() { - $this->eoi = false; - $this->cache = array(); - // XXX: Should the inner be recreated to refetch? - $this->inner->rewind(); - } - function asArray() { $this->fillTo(PHP_INT_MAX); return $this->getCache(); } function getCache() { - return $this->cache; + return $this->storage; + } + + function reset() { + $this->eoi = false; + $this->storage = array(); + // XXX: Should the inner be recreated to refetch? + $this->inner->rewind(); } function getIterator() { $this->asArray(); - return new ArrayIterator($this->cache); + return new ArrayIterator($this->storage); } function offsetExists($offset) { $this->fillTo($offset+1); - return count($this->cache) > $offset; + return count($this->storage) > $offset; } function offsetGet($offset) { $this->fillTo($offset+1); - return $this->cache[$offset]; + return $this->storage[$offset]; } function offsetUnset($a) { throw new Exception(__('QuerySet is read-only')); @@ -1616,7 +1618,7 @@ implements IteratorAggregate, Countable, ArrayAccess { function count() { $this->asArray(); - return count($this->cache); + return count($this->storage); } /** @@ -1636,22 +1638,7 @@ implements IteratorAggregate, Countable, ArrayAccess { function sort($key=false, $reverse=false) { // Fetch all records into the cache $this->asArray(); - if (is_callable($key)) { - array_multisort( - array_map($key, $this->cache), - $reverse ? SORT_DESC : SORT_ASC, - $this->cache); - } - elseif ($key) { - array_multisort($this->cache, - $reverse ? SORT_DESC : SORT_ASC, $key); - } - elseif ($reverse) { - rsort($this->cache); - } - else - sort($this->cache); - return $this; + return parent::sort($key, $reverse); } /** @@ -1659,8 +1646,7 @@ implements IteratorAggregate, Countable, ArrayAccess { */ function reverse() { $this->asArray(); - array_reverse($this->cache); - return $this; + return parent::reverse(); } } @@ -1695,7 +1681,7 @@ extends CachedResultSet { * database. */ function findAll($criteria, $limit=false) { - $records = array(); + $records = new ListObject(); foreach ($this as $record) { $matches = true; foreach ($criteria as $field=>$check) { @@ -1984,14 +1970,16 @@ class InstrumentedList extends ModelResultSet { var $key; - function __construct($fkey, $queryset=false) { + function __construct($fkey, $queryset=false, + $iterator='ModelInstanceManager' + ) { list($model, $this->key) = $fkey; if (!$queryset) { $queryset = $model::objects()->filter($this->key); if ($related = $model::getMeta('select_related')) $queryset->select_related($related); } - parent::__construct(new ModelInstanceManager($queryset)); + parent::__construct(new $iterator($queryset)); $this->model = $model; $this->queryset = $queryset; } @@ -2013,9 +2001,9 @@ extends ModelResultSet { $object->save(); if ($at !== false) - $this->cache[$at] = $object; + $this->storage[$at] = $object; else - $this->cache[] = $object; + $this->storage[] = $object; return $object; } @@ -2072,12 +2060,11 @@ extends ModelResultSet { * XXX: Move this to a parent class? */ function setCache(array $cache) { - if (count($this->cache) > 0) + if (count($this->storage) > 0) throw new Exception('Cache must be set before fetching records'); // Set cache and disable fetching $this->reset(); - $this->cache = $cache; - $this->resource = false; + $this->storage = $cache; } // Save all changes made to any list items @@ -2107,11 +2094,11 @@ extends ModelResultSet { function offsetUnset($a) { $this->fillTo($a); - $this->cache[$a]->delete(); + $this->storage[$a]->delete(); } function offsetSet($a, $b) { $this->fillTo($a); - if ($obj = $this->cache[$a]) + if ($obj = $this->storage[$a]) $obj->delete(); $this->add($b, $a); } diff --git a/include/class.queue.php b/include/class.queue.php index 143e37ba0b8063184e1b0e7a45ab95786e8a23a4..bd0775da7ee17ec44d5150626ed6b594f3727fdb 100644 --- a/include/class.queue.php +++ b/include/class.queue.php @@ -14,30 +14,442 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -require_once INCLUDE_DIR . 'class.search.php'; -class CustomQueue extends SavedSearch { +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', ), - 'children' => array( - 'reverse' => 'CustomQueue.parent', + '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->parent_id + 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 ) { @@ -46,7 +458,46 @@ class CustomQueue extends SavedSearch { elseif (count($this->columns)) { return $this->columns; } - return parent::getColumns(); + + // 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) { @@ -113,7 +564,7 @@ class CustomQueue extends SavedSearch { if (isset($quick_filter) && ($qf = $this->getQuickFilterField($quick_filter)) ) { - $this->filter = @SavedSearch::getOrmPath($this->filter, $query); + $this->filter = @self::getOrmPath($this->filter, $query); $query = $qf->applyQuickFilter($query, $quick_filter, $this->filter); } @@ -132,7 +583,7 @@ class CustomQueue extends SavedSearch { } } elseif ($this->filter - && ($fields = SavedSearch::getSearchableFields($this->getRoot())) + && ($fields = self::getSearchableFields($this->getRoot())) && (list(,$f) = @$fields[$this->filter]) && $f->supportsQuickFilter() ) { @@ -141,15 +592,122 @@ class CustomQueue extends SavedSearch { } } + /** + * 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()) { - if (!parent::update($vars, false, $errors)) - return false; + // 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'])) { @@ -170,7 +728,7 @@ class CustomQueue extends SavedSearch { } // Add new columns foreach ($new as $info) { - $glue = QueueColumnGlue::create(array( + $glue = new QueueColumnGlue(array( 'column_id' => $info['column_id'], 'sort' => array_search($info['column_id'], $order), 'heading' => $info['heading'], @@ -188,12 +746,27 @@ class CustomQueue extends SavedSearch { // 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 (!($rv = parent::save($refetch))) + + if ($this->dirty) + $this->updated = SqlFunction::NOW(); + if (!($rv = parent::save($refetch || $this->dirty))) return $rv; if ($wasnew) { @@ -203,11 +776,41 @@ class CustomQueue extends SavedSearch { 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 = parent::create($vars); - $queue->setFlag(SavedSearch::FLAG_QUEUE); + $queue = new static($vars); + $queue->created = SqlFunction::NOW(); + $queue->setFlag(self::FLAG_QUEUE); if ($thisstaff) $queue->staff_id = $thisstaff->getId(); @@ -218,7 +821,7 @@ class CustomQueue extends SavedSearch { $q = static::create($vars); $q->save(); foreach ($vars['columns'] as $info) { - $glue = QueueColumnGlue::create($info); + $glue = new QueueColumnGlue($info); $glue->queue_id = $q->getId(); $glue->save(); } @@ -474,7 +1077,7 @@ extends ChoiceField { $config = $this->getConfiguration(); $root = $config['root']; $fields = array(); - foreach (SavedSearch::getSearchableFields($root) as $path=>$f) { + foreach (CustomQueue::getSearchableFields($root) as $path=>$f) { list($label,) = $f; $fields[$path] = $label; } @@ -514,7 +1117,7 @@ class QueueColumnCondition { // FIXME #$root = $this->getColumn()->getRoot(); $root = 'Ticket'; - $searchable = SavedSearch::getSearchableFields($root); + $searchable = CustomQueue::getSearchableFields($root); if (!isset($name)) list($name) = $this->config['crit']; @@ -539,7 +1142,7 @@ class QueueColumnCondition { // XXX: Move getOrmPath to be more of a utility // Ensure the special join is created to support custom data joins - $name = @SavedSearch::getOrmPath($name, $query); + $name = @CustomQueue::getOrmPath($name, $query); $name2 = null; if (preg_match('/__answers!\d+__/', $name)) { @@ -560,7 +1163,7 @@ class QueueColumnCondition { * specific data entered in the UI. */ static function isolateCriteria($criteria, $root='Ticket') { - $searchable = SavedSearch::getSearchableFields($root); + $searchable = CustomQueue::getSearchableFields($root); foreach ($criteria as $k=>$v) { if (substr($k, -7) === '+method') { list($name,) = explode('+', $k, 2); @@ -836,9 +1439,9 @@ extends VerySimpleModel { function renderBasicValue($row) { $root = ($q = $this->getQueue()) ? $q->getRoot() : 'Ticket'; - $fields = SavedSearch::getSearchableFields($root); - $primary = SavedSearch::getOrmPath($this->primary); - $secondary = SavedSearch::getOrmPath($this->secondary); + $fields = CustomQueue::getSearchableFields($root); + $primary = CustomQueue::getOrmPath($this->primary); + $secondary = CustomQueue::getOrmPath($this->secondary); // Return a lazily ::display()ed value so that the value to be // rendered by the field could be changed or display()ed when @@ -894,16 +1497,16 @@ extends VerySimpleModel { function mangleQuery($query, $root=null) { // Basic data - $fields = SavedSearch::getSearchableFields($root ?: $this->getQueue()->getRoot()); + $fields = CustomQueue::getSearchableFields($root ?: $this->getQueue()->getRoot()); if ($primary = $fields[$this->primary]) { list(,$field) = $primary; $query = $this->addToQuery($query, $field, - SavedSearch::getOrmPath($this->primary, $query)); + CustomQueue::getOrmPath($this->primary, $query)); } if ($secondary = $fields[$this->secondary]) { list(,$field) = $secondary; $query = $this->addToQuery($query, $field, - SavedSearch::getOrmPath($this->secondary, $query)); + CustomQueue::getOrmPath($this->secondary, $query)); } if ($filter = $this->getFilter()) @@ -956,7 +1559,7 @@ extends VerySimpleModel { } static function __create($vars) { - $c = static::create($vars); + $c = new static($vars); $c->save(); return $c; } @@ -989,11 +1592,11 @@ extends VerySimpleModel { continue; // Determine the criteria $name = $vars['condition_field'][$i]; - $fields = SavedSearch::getSearchableFields($root); + $fields = CustomQueue::getSearchableFields($root); if (!isset($fields[$name])) // No such field exists for this queue root type continue; - $parts = SavedSearch::getSearchField($fields[$name], $name); + $parts = CustomQueue::getSearchField($fields[$name], $name); $search_form = new SimpleForm($parts, $vars, array('id' => $id)); $search_form->getField("{$name}+search")->value = true; $crit = $search_form->getClean(); @@ -1049,13 +1652,8 @@ extends VerySimpleModel { ); } -class QueueColumnListBroker -extends InstrumentedList { - function __construct($fkey, $queryset=false) { - parent::__construct($fkey, $queryset); - $this->queryset->select_related('column'); - } - +class QueueColumnGlueMIM +extends ModelInstanceManager { function getOrBuild($modelClass, $fields, $cache=true) { $m = parent::getOrBuild($modelClass, $fields, $cache); if ($m && $modelClass === 'QueueColumnGlue') { @@ -1065,9 +1663,17 @@ extends InstrumentedList { } return $m; } +} + +class QueueColumnListBroker +extends InstrumentedList { + function __construct($fkey, $queryset=false) { + parent::__construct($fkey, $queryset, 'QueueColumnGlueMIM'); + $this->queryset->select_related('column'); + } function add($column, $glue=null) { - $glue = $glue ?: QueueColumnGlue::create(); + $glue = $glue ?: new QueueColumnGlue(); $glue->column = $column; $anno = AnnotatedModel::wrap($column, $glue); parent::add($anno); diff --git a/include/class.search.php b/include/class.search.php index a203040185a42900a8f75a9f771862b0e2007e32..66eae2ba56a335a03749a9015c9a2a416cae3bae 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -23,6 +23,7 @@ **********************************************************************/ require_once INCLUDE_DIR . 'class.role.php'; require_once INCLUDE_DIR . 'class.list.php'; +require_once INCLUDE_DIR . 'class.queue.php'; abstract class SearchBackend { static $id = false; @@ -645,45 +646,11 @@ MysqlSearchBackend::register(); // Saved search system /** - * - * Fields: - * id - (int:unsigned:auto:pk) unique identifier - * flags - (int:unsigned) flags for this queue - * staff_id - (int:unsigned) Agent to whom this queue belongs (can be null - * for public saved searches) - * title - (text:60) name of the queue - * config - (text) JSON encoded search configuration for the queue - * created - (date) date initially created - * updated - (date:auto_update) time of last update + * A special case of the custom queues used to represent an advanced search. */ -class SavedSearch extends VerySimpleModel { - static $meta = array( - 'table' => QUEUE_TABLE, - 'pk' => array('id'), - 'ordering' => array('sort'), - 'joins' => array( - 'staff' => array( - 'constraint' => array( - 'staff_id' => 'Staff.staff_id', - ) - ), - 'parent' => array( - 'constraint' => array( - 'parent_id' => 'CustomQueue.id', - ), - 'null' => true, - ), - ), - ); - - 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; - private $columns; +class SavedSearch extends CustomQueue { + // Override the ORM relationship to force no children + private $children = false; static function forStaff(Staff $agent) { return static::objects()->filter(Q::any(array( @@ -693,627 +660,27 @@ class SavedSearch extends VerySimpleModel { ->exclude(array('flags__hasbit'=>self::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, self::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(); - } - - // Last resort — use standard columns - return array( - QueueColumn::create(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::create(array( - "heading" => "Created", - "primary" => 'created', - "width" => 100, - )), - QueueColumn::create(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::create(array( - "heading" => "From", - "primary" => 'user__name', - "width" => 150, - )), - QueueColumn::create(array( - "heading" => "Priority", - "primary" => 'cdata__priority', - "width" => 120, - )), - QueueColumn::create(array( - "heading" => "Assignee", - "primary" => 'assignee', - "width" => 100, - )), - ); - } - - /** - * 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 getQuery() { - $root = $this->getRoot(); - $base = $root::objects(); - $query = $this->mangleQuerySet($base); - - // Apply column, annotations and conditions additions - foreach ($this->getColumns() as $C) { - $query = $C->mangleQuery($query, $this->getRoot()); - } - return $query; - } - - 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; - } - - 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; - } - - - 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 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); - } - - static function create($vars=array()) { - $inst = new static($vars); - $inst->created = SqlFunction::NOW(); - return $inst; - } - - function save($refetch=false) { - if ($this->dirty) - $this->updated = SqlFunction::NOW(); - return parent::save($refetch || $this->dirty); - } - function update($vars, $form=false, &$errors=array()) { - // Set basic search information - if (!$vars['name']) - $errors['name'] = __('A title is required'); + if (!parent::update($vars, $errors)) + return false; - $this->title = $vars['name']; - $this->parent_id = @$vars['parent_id'] ?: 0; - $this->path = $this->buildPath(); // Personal queues _always_ inherit from their parent $this->setFlag(self::FLAG_INHERIT_CRITERIA, $this->parent_id > 0); - // 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 count($errors) === 0; } + + static function create() { + $search = parent::create(); + $search->clearFlag(self::FLAG_QUEUE); + return $search; + } } class AdhocSearch extends SavedSearch { function getName() { - return __('Ad-Hoc Search'); - } - - function getHref() { - return 'tickets.php?queue=adhoc'; + return $this->describeCriteria(); } } diff --git a/include/class.ticket.php b/include/class.ticket.php index 7cbc22ce9cd02bfa72a0454acab03113199b1855..e08fab62d8c975bace4fb206889a75d59b289c24 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -3731,7 +3731,7 @@ class TicketCData extends VerySimpleModel { 'ticket' => array( 'constraint' => array('ticket_id' => 'Ticket.ticket_id'), ), - 'priority' => array( + ':priority' => array( 'constraint' => array('priority' => 'Priority.priority_id'), 'null' => true, ), diff --git a/include/class.util.php b/include/class.util.php index b4adf4985ae2b1321d9247ab0ea6a5ae4359ae2a..a56f23b2ce70c43f95c3b63c3d90987ab731331b 100644 --- a/include/class.util.php +++ b/include/class.util.php @@ -1,4 +1,54 @@ <?php + +abstract class BaseList +implements IteratorAggregate, Countable { + protected $storage = array(); + + /** + * Sort the list in place. + * + * Parameters: + * $key - (callable|int) A callable function to produce the sort keys + * or one of the SORT_ constants used by the array_multisort + * function + * $reverse - (bool) true if the list should be sorted descending + */ + function sort($key=false, $reverse=false) { + if (is_callable($key)) { + $keys = array_map($key, $this->storage); + array_multisort($keys, $this->storage, + $reverse ? SORT_DESC : SORT_ASC); + } + elseif ($key) { + array_multisort($this->storage, + $reverse ? SORT_DESC : SORT_ASC, $key); + } + elseif ($reverse) { + rsort($this->storage); + } + else + sort($this->storage); + } + + function reverse() { + return array_reverse($this->storage); + } + + // IteratorAggregate + function getIterator() { + return new ArrayIterator($this->storage); + } + + // Countable + function count($mode=COUNT_NORMAL) { + return count($this->storage, $mode); + } + + function __toString() { + return '['.implode(', ', $this->storage).']'; + } +} + /** * Jared Hancock <jared@osticket.com> * Copyright (c) 2014 @@ -11,9 +61,9 @@ * Negative indexes are supported which reference from the end of the list. * Therefore $queue[-1] will refer to the last item in the list. */ -class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Countable { - - protected $storage = array(); +class ListObject +extends BaseList +implements ArrayAccess, Serializable { function __construct($array=array()) { if (!is_array($array) && !$array instanceof Traversable) @@ -73,36 +123,6 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta return array_search($this->storage, $value); } - /** - * Sort the list in place. - * - * Parameters: - * $key - (callable|int) A callable function to produce the sort keys - * or one of the SORT_ constants used by the array_multisort - * function - * $reverse - (bool) true if the list should be sorted descending - */ - function sort($key=false, $reverse=false) { - if (is_callable($key)) { - $keys = array_map($key, $this->storage); - array_multisort($keys, $this->storage, - $reverse ? SORT_DESC : SORT_ASC); - } - elseif ($key) { - array_multisort($this->storage, - $reverse ? SORT_DESC : SORT_ASC, $key); - } - elseif ($reverse) { - rsort($this->storage); - } - else - sort($this->storage); - } - - function reverse() { - return array_reverse($this->storage); - } - function filter($callable) { $new = new static(); foreach ($this->storage as $i=>$v) @@ -111,16 +131,6 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta return $new; } - // IteratorAggregate - function getIterator() { - return new ArrayIterator($this->storage); - } - - // Countable - function count($mode=COUNT_NORMAL) { - return count($this->storage, $mode); - } - // ArrayAccess function offsetGet($offset) { if (!is_int($offset)) @@ -166,8 +176,4 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta function unserialize($what) { $this->storage = unserialize($what); } - - function __toString() { - return '['.implode(', ', $this->storage).']'; - } } diff --git a/include/staff/queue.inc.php b/include/staff/queue.inc.php index afaed941a412b49787d4b44e720f8699c6b2a5df..9c85a0497719711988a6b683c2b45ec8b9dd0f19 100644 --- a/include/staff/queue.inc.php +++ b/include/staff/queue.inc.php @@ -108,7 +108,7 @@ else { if ($queue->parent && ($qf = $queue->parent->getQuickFilterField())) echo sprintf(' (%s)', $qf->getLabel()); ?> —</option> -<?php foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) { +<?php foreach (CustomQueue::getSearchableFields('Ticket') as $path=>$f) { list($label, $field) = $f; if (!$field->supportsQuickFilter()) continue; @@ -128,7 +128,20 @@ else { <div class="hidden tab_content" id="columns"> <table class="table two-column"> +<?php if ($queue->parent) { ?> <tbody> + <tr> + <td colspan="3"> + <input type="checkbox" name="inherit-columns" <?php + if ($queue->inheritColumns()) echo 'checked="checked"'; ?> + onchange="javascript:$(this).closest('table').find('.if-not-inherited').toggle(!$(this).prop('checked'));" /> + <?php echo __('Inherit columns from the parent queue'); ?> + <br /><br /> + </td> + </tr> + </tbody> +<?php } ?> + <tbody class="if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>"> <tr class="header"> <th colspan="3"> <?php echo __("Manage columns in this queue"); ?> @@ -143,7 +156,7 @@ else { <td><small><b><?php echo __('Sortable'); ?></b></small></td> </tr> </tbody> - <tbody class="sortable-rows"> + <tbody class="sortable-rows if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>"> <tr id="column-template" class="hidden"> <td> <i class="faded-more icon-sort"></i> @@ -172,7 +185,7 @@ else { </td> </tr> </tbody> - <tbody> + <tbody class="if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>"> <tr class="header"> <td colspan="3"></td> </tr> diff --git a/include/staff/queues-ticket.inc.php b/include/staff/queues-ticket.inc.php index acf872529df8b38f079667347504452feee46e09..77efa54d8ce0295e1b7f29d693e8a682b9d59344 100644 --- a/include/staff/queues-ticket.inc.php +++ b/include/staff/queues-ticket.inc.php @@ -46,7 +46,7 @@ </thead> <tbody class="sortable-rows" data-sort="qsort"> <?php -$all_queues = CustomQueue::queues()->all(); +$all_queues = CustomQueue::queues()->getIterator(); $emitLevel = function($queues, $level=0) use ($all_queues, &$emitLevel) { $queues->sort(function($a) { return $a->sort; }); foreach ($queues as $q) { ?> diff --git a/include/staff/templates/queue-column-condition.tmpl.php b/include/staff/templates/queue-column-condition.tmpl.php index 40f6d6fb6a69f00c343ee1a63d6b59344c2cc301..bc69978c877a9ed2043866091f55bd46f8d1eee4 100644 --- a/include/staff/templates/queue-column-condition.tmpl.php +++ b/include/staff/templates/queue-column-condition.tmpl.php @@ -20,7 +20,7 @@ <?php echo $label ?: $field->getLabel(); ?> <div class="advanced-search"> <?php -$parts = SavedSearch::getSearchField(array($label, $field), $field_name); +$parts = CustomQueue::getSearchField(array($label, $field), $field_name); // Drop the search checkbox field unset($parts["{$field_name}+search"]); list(, $crit_method, $crit_value) = $condition->getCriteria(); diff --git a/include/staff/templates/queue-column.tmpl.php b/include/staff/templates/queue-column.tmpl.php index 41e99bc048660035f032d0a30d75495a5c750ad3..851d374cd8ae67225177868743ddfd1c5d650ffe 100644 --- a/include/staff/templates/queue-column.tmpl.php +++ b/include/staff/templates/queue-column.tmpl.php @@ -118,7 +118,7 @@ foreach (Internationalization::sortKeyedList($annotations) as $class=>$desc) { <div class="conditions"> <?php if ($column->getConditions()) { - $fields = SavedSearch::getSearchableFields($root); + $fields = CustomQueue::getSearchableFields($root); foreach ($column->getConditions() as $i=>$condition) { $id = QueueColumnCondition::getUid(); list($label, $field) = $condition->getField(); @@ -131,7 +131,7 @@ if ($column->getConditions()) { <select class="add-condition"> <option>— <?php echo __("Add a condition"); ?> —</option> <?php - foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) { + foreach (CustomQueue::getSearchableFields('Ticket') as $path=>$f) { list($label) = $f; echo sprintf('<option value="%s">%s</option>', $path, Format::htmlchars($label)); } diff --git a/include/staff/templates/queue-savedsearches-nav.tmpl.php b/include/staff/templates/queue-savedsearches-nav.tmpl.php index 81646edf71d6440fd0369374fd991a3aedc3840a..3425ac4a563117c8d0ec4805a046848d0459c60d 100644 --- a/include/staff/templates/queue-savedsearches-nav.tmpl.php +++ b/include/staff/templates/queue-savedsearches-nav.tmpl.php @@ -20,7 +20,7 @@ 'staff_id' => $thisstaff->getId(), 'parent_id' => 0, Q::not(array( - 'flags__hasbit' => SavedSearch::FLAG_PUBLIC + 'flags__hasbit' => CustomQueue::FLAG_PUBLIC )) )) as $q) { include 'queue-subnavigation.tmpl.php'; diff --git a/include/staff/templates/queue-subnavigation.tmpl.php b/include/staff/templates/queue-subnavigation.tmpl.php index ddf345cfba5871cb18ea242f20ec9553d661d768..b0cbeeb75d98b1db5ac61e7004b1cd20aa48c732 100644 --- a/include/staff/templates/queue-subnavigation.tmpl.php +++ b/include/staff/templates/queue-subnavigation.tmpl.php @@ -2,8 +2,8 @@ // Calling conventions // $q - <CustomQueue> object for this navigation entry $queue = $q; -$children = $queue instanceof CustomQueue ? $queue->getPublicChildren() : array(); -$subq_searches = $queue instanceof CustomQueue ? $queue->getMyChildren() : array(); +$children = !$queue instanceof SavedSearch ? $queue->getPublicChildren() : array(); +$subq_searches = !$queue instanceof SavedSearch ? $queue->getMyChildren() : array(); $hasChildren = count($children) + count($subq_searches) > 0; $selected = $_REQUEST['queue'] == $q->getId(); global $thisstaff; diff --git a/include/staff/templates/queue-tickets.tmpl.php b/include/staff/templates/queue-tickets.tmpl.php index bd41e7fdbc523cc3c9c19845dd51f725eac6b8e8..7725d04deea4a9c37a2de5e22d775ef644b8ce44 100644 --- a/include/staff/templates/queue-tickets.tmpl.php +++ b/include/staff/templates/queue-tickets.tmpl.php @@ -103,7 +103,7 @@ if ($queue->isPrivate()) { ?> <li> <a class="no-pjax" href="#" data-dialog="ajax.php/tickets/search/<?php echo - $queue->id; ?>"><i + urlencode($queue->getId()); ?>"><i class="icon-fixed-width icon-save"></i> <?php echo __('Edit'); ?></a> </li> @@ -192,7 +192,7 @@ foreach ($columns as $C) { // Sort by this column ? if (isset($sort['col']) && $sort['col'] == $C->id) { - $col = SavedSearch::getOrmPath($C->primary, $query); + $col = CustomQueue::getOrmPath($C->primary, $query); if ($sort['dir']) $col = '-' . $col; $tickets = $tickets->order_by($col); diff --git a/scp/tickets.php b/scp/tickets.php index bb4eabedb3324ff290c665f4f2f508e14e6cbcf6..aaaf653d01a33837d769d3c1ea43ce60eba70d3b 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -67,8 +67,8 @@ elseif (isset($_SESSION['advsearch']) ) { list(,$key) = explode(',', $queue_id, 2); // XXX: De-duplicate and simplify this code - $queue = SavedSearch::create(array( - 'title' => __("Advanced Search"), + $queue = AdhocSearch::create(array( + 'id' => $queue_id, 'root' => 'T', )); // For queue=queue, use the most recent search @@ -416,15 +416,7 @@ $nav->addSubMenu(function() use ($queue, $adhoc) { // A queue is selected if it is the one being displayed. It is // "child" selected if its ID is in the path of the one selected $child_selected = $queue && !$queue->isAQueue(); - $searches = SavedSearch::objects() - ->filter(Q::any(array( - 'flags__hasbit' => SavedSearch::FLAG_PUBLIC, - 'staff_id' => $thisstaff->getId(), - ))) - ->exclude(array( - 'flags__hasbit' => SavedSearch::FLAG_QUEUE - )) - ->all(); + $searches = SavedSearch::forStaff($thisstaff)->all(); if (isset($adhoc)) { // TODO: Add "Ad Hoc Search" to the personal children