You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1946 lines
61 KiB
1946 lines
61 KiB
<?php
|
|
/*********************************************************************
|
|
module.search.php
|
|
|
|
Search Engine for osTicket
|
|
|
|
This module defines the pieces for a search engine for osTicket.
|
|
Searching can be performed by various search engine backends which can
|
|
make use of the features of various search providers.
|
|
|
|
A reference search engine backend is provided which uses MySQL MyISAM
|
|
tables. This default backend should not be used on Galera clusters.
|
|
|
|
Jared Hancock <jared@osticket.com>
|
|
Peter Rotich <peter@osticket.com>
|
|
Copyright (c) 2006-2013 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:
|
|
**********************************************************************/
|
|
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;
|
|
static $registry = array();
|
|
|
|
const SORT_RELEVANCE = 1;
|
|
const SORT_RECENT = 2;
|
|
const SORT_OLDEST = 3;
|
|
|
|
const PERM_EVERYTHING = 'search.all';
|
|
|
|
static protected $perms = array(
|
|
self::PERM_EVERYTHING => array(
|
|
'title' => /* @trans */ 'Search',
|
|
'desc' => /* @trans */ 'See all tickets in search results, regardless of access',
|
|
'primary' => true,
|
|
),
|
|
);
|
|
|
|
abstract function update($model, $id, $content, $new=false, $attrs=array());
|
|
abstract function find($query, QuerySet $criteria, $addRelevance=true);
|
|
|
|
static function register($backend=false) {
|
|
$backend = $backend ?: get_called_class();
|
|
|
|
if ($backend::$id == false)
|
|
throw new Exception('SearchBackend must define an ID');
|
|
|
|
static::$registry[$backend::$id] = $backend;
|
|
}
|
|
|
|
function getInstance($id) {
|
|
if (!isset(self::$registry[$id]))
|
|
return null;
|
|
|
|
return new self::$registry[$id]();
|
|
}
|
|
|
|
static function getPermissions() {
|
|
return self::$perms;
|
|
}
|
|
}
|
|
RolePermission::register(/* @trans */ 'Miscellaneous', SearchBackend::getPermissions());
|
|
|
|
// Register signals to intercept saving of various content throughout the
|
|
// system
|
|
|
|
class SearchInterface {
|
|
|
|
var $backend;
|
|
|
|
function __construct() {
|
|
$this->bootstrap();
|
|
}
|
|
|
|
function find($query, QuerySet $criteria, $addRelevance=true) {
|
|
$query = Format::searchable($query);
|
|
return $this->backend->find($query, $criteria, $addRelevance);
|
|
}
|
|
|
|
function update($model, $id, $content, $new=false, $attrs=array()) {
|
|
if ($this->backend)
|
|
$this->backend->update($model, $id, $content, $new, $attrs);
|
|
}
|
|
|
|
function createModel($model) {
|
|
return $this->updateModel($model, true);
|
|
}
|
|
|
|
function deleteModel($model) {
|
|
if ($this->backend)
|
|
$this->backend->delete($model);
|
|
}
|
|
|
|
function updateModel($model, $new=false) {
|
|
// The MySQL backend does not need to index attributes of the
|
|
// various models, because those other attributes are available in
|
|
// the local database in other tables.
|
|
switch (true) {
|
|
case $model instanceof ThreadEntry:
|
|
// Only index an entry for threads if a human created the
|
|
// content
|
|
if (!$model->getUserId() && !$model->getStaffId())
|
|
break;
|
|
|
|
$this->update($model, $model->getId(),
|
|
$model->getBody()->getSearchable(),
|
|
$new === true,
|
|
array(
|
|
'title' => $model->getTitle(),
|
|
'created' => $model->getCreateDate(),
|
|
)
|
|
);
|
|
break;
|
|
|
|
case $model instanceof Ticket:
|
|
$cdata = array();
|
|
foreach ($model->loadDynamicData() as $a)
|
|
if ($v = $a->getSearchable())
|
|
$cdata[] = $v;
|
|
$this->update($model, $model->getId(),
|
|
trim(implode("\n", $cdata)),
|
|
$new === true,
|
|
array(
|
|
'title'=> Format::searchable($model->getSubject()),
|
|
'number'=> $model->getNumber(),
|
|
'status'=> $model->getStatus(),
|
|
'topic_id'=> $model->getTopicId(),
|
|
'priority_id'=> $model->getPriorityId(),
|
|
// Stats (comments, attachments)
|
|
// Access constraints
|
|
'dept_id'=> $model->getDeptId(),
|
|
'staff_id'=> $model->getStaffId(),
|
|
'team_id'=> $model->getTeamId(),
|
|
// Sorting and ranging preferences
|
|
'created'=> $model->getCreateDate(),
|
|
// Add last-updated timestamp
|
|
)
|
|
);
|
|
break;
|
|
|
|
case $model instanceof User:
|
|
$cdata = array();
|
|
foreach ($model->getDynamicData($false) as $e)
|
|
foreach ($e->getAnswers() as $tag=>$a)
|
|
if ($tag != 'subject' && ($v = $a->getSearchable()))
|
|
$cdata[] = $v;
|
|
$this->update($model, $model->getId(),
|
|
trim(implode("\n", $cdata)),
|
|
$new === true,
|
|
array(
|
|
'title'=> Format::searchable($model->getFullName()),
|
|
'emails'=> $model->emails->asArray(),
|
|
'org_id'=> $model->getOrgId(),
|
|
'created'=> $model->getCreateDate(),
|
|
)
|
|
);
|
|
break;
|
|
|
|
case $model instanceof Organization:
|
|
$cdata = array();
|
|
foreach ($model->getDynamicData(false) as $e)
|
|
foreach ($e->getAnswers() as $a)
|
|
if ($v = $a->getSearchable())
|
|
$cdata[] = $v;
|
|
$this->update($model, $model->getId(),
|
|
trim(implode("\n", $cdata)),
|
|
$new === true,
|
|
array(
|
|
'title'=> Format::searchable($model->getName()),
|
|
'created'=> $model->getCreateDate(),
|
|
)
|
|
);
|
|
break;
|
|
|
|
case $model instanceof FAQ:
|
|
$this->update($model, $model->getId(),
|
|
$model->getSearchableAnswer(),
|
|
$new === true,
|
|
array(
|
|
'title'=> Format::searchable($model->getQuestion()),
|
|
'keywords'=> $model->getKeywords(),
|
|
'topics'=> $model->getHelpTopicsIds(),
|
|
'category_id'=> $model->getCategoryId(),
|
|
'created'=> $model->getCreateDate(),
|
|
)
|
|
);
|
|
break;
|
|
|
|
default:
|
|
// Not indexed
|
|
break;
|
|
}
|
|
}
|
|
|
|
function bootstrap() {
|
|
// Determine the backend
|
|
if (defined('SEARCH_BACKEND'))
|
|
$bk = SearchBackend::getInstance(SEARCH_BACKEND);
|
|
|
|
if (!$bk && !($bk = SearchBackend::getInstance('mysql')))
|
|
// No backend registered or defined
|
|
return false;
|
|
|
|
$this->backend = $bk;
|
|
$this->backend->bootstrap();
|
|
|
|
$self = $this;
|
|
|
|
// Thread entries
|
|
// Tickets, which can be edited as well
|
|
// Knowledgebase articles (FAQ and canned responses)
|
|
// Users, organizations
|
|
Signal::connect('threadentry.created', array($this, 'createModel'));
|
|
Signal::connect('ticket.created', array($this, 'createModel'));
|
|
Signal::connect('user.created', array($this, 'createModel'));
|
|
Signal::connect('organization.created', array($this, 'createModel'));
|
|
Signal::connect('model.created', array($this, 'createModel'), 'FAQ');
|
|
|
|
Signal::connect('model.updated', array($this, 'updateModel'));
|
|
Signal::connect('model.deleted', array($this, 'deleteModel'));
|
|
}
|
|
}
|
|
|
|
require_once(INCLUDE_DIR.'class.config.php');
|
|
class MySqlSearchConfig extends Config {
|
|
var $table = CONFIG_TABLE;
|
|
|
|
function __construct() {
|
|
parent::__construct("mysqlsearch");
|
|
}
|
|
}
|
|
|
|
class MysqlSearchBackend extends SearchBackend {
|
|
static $id = 'mysql';
|
|
static $BATCH_SIZE = 30;
|
|
|
|
// Only index 20 batches per cron run
|
|
var $max_batches = 60;
|
|
var $_reindexed = 0;
|
|
var $SEARCH_TABLE;
|
|
|
|
function __construct() {
|
|
$this->SEARCH_TABLE = TABLE_PREFIX . '_search';
|
|
}
|
|
|
|
function getConfig() {
|
|
if (!isset($this->config))
|
|
$this->config = new MySqlSearchConfig();
|
|
return $this->config;
|
|
}
|
|
|
|
|
|
function bootstrap() {
|
|
if ($this->getConfig()->get('reindex', true))
|
|
Signal::connect('cron', array($this, 'IndexOldStuff'));
|
|
}
|
|
|
|
function update($model, $id, $content, $new=false, $attrs=array()) {
|
|
if (!($type=ObjectModel::getType($model)))
|
|
return;
|
|
|
|
if ($model instanceof Ticket)
|
|
$attrs['title'] = $attrs['number'].' '.$attrs['title'];
|
|
elseif ($model instanceof User)
|
|
$content .=' '.implode("\n", $attrs['emails']);
|
|
|
|
$title = $attrs['title'] ?: '';
|
|
|
|
if (!$content && !$title)
|
|
return;
|
|
if (!$id)
|
|
return;
|
|
|
|
$sql = 'REPLACE INTO '.$this->SEARCH_TABLE
|
|
. ' SET object_type='.db_input($type)
|
|
. ', object_id='.db_input($id)
|
|
. ', content='.db_input($content)
|
|
. ', title='.db_input($title);
|
|
return db_query($sql, false);
|
|
}
|
|
|
|
function delete($model) {
|
|
switch (true) {
|
|
case $model instanceof Thread:
|
|
$sql = 'DELETE s.* FROM '.$this->SEARCH_TABLE
|
|
. " s JOIN ".THREAD_ENTRY_TABLE." h ON (h.id = s.object_id) "
|
|
. " WHERE s.object_type='H'"
|
|
. ' AND h.thread_id='.db_input($model->getId());
|
|
return db_query($sql);
|
|
|
|
default:
|
|
if (!($type = ObjectModel::getType($model)))
|
|
return;
|
|
|
|
$sql = 'DELETE FROM '.$this->SEARCH_TABLE
|
|
. ' WHERE object_type='.db_input($type)
|
|
. ' AND object_id='.db_input($model->getId());
|
|
return db_query($sql);
|
|
}
|
|
}
|
|
|
|
// Quote things like email addresses
|
|
function quote($query) {
|
|
$parts = array();
|
|
if (!preg_match_all('`(?:([^\s"\']+)|"[^"]*"|\'[^\']*\')(\s*)`', $query, $parts,
|
|
PREG_SET_ORDER))
|
|
return $query;
|
|
|
|
$results = array();
|
|
foreach ($parts as $m) {
|
|
// Check for quoting
|
|
if ($m[1] // Already quoted?
|
|
&& preg_match('`@`u', $m[0])
|
|
) {
|
|
$char = strpos($m[1], '"') ? "'" : '"';
|
|
$m[0] = $char . $m[0] . $char;
|
|
}
|
|
$results[] = $m[0].$m[2];
|
|
}
|
|
return implode('', $results);
|
|
}
|
|
|
|
function find($query, QuerySet $criteria, $addRelevance=true) {
|
|
global $thisstaff;
|
|
|
|
// MySQL usually doesn't handle words shorter than three letters
|
|
// (except with special configuration)
|
|
if (strlen($query) < 3)
|
|
return $criteria;
|
|
|
|
$criteria = clone $criteria;
|
|
|
|
$mode = ' IN NATURAL LANGUAGE MODE';
|
|
|
|
// According to the MySQL full text boolean mode, this grammar is
|
|
// assumed:
|
|
// see http://dev.mysql.com/doc/refman/5.6/en/fulltext-boolean.html
|
|
//
|
|
// PREOP = [<>~+-]
|
|
// POSTOP = [*]
|
|
// WORD = [\w][\w-]*
|
|
// TERM = PREOP? WORD POSTOP?
|
|
// QWORD = " [^"]+ "
|
|
// PARENS = \( { { TERM | QWORD } { \s+ { TERM | QWORD } }+ } \)
|
|
// EXPR = { PREOP? PARENS | TERM | QWORD }
|
|
// BOOLEAN = EXPR { \s+ EXPR }*
|
|
//
|
|
// Changing '{' for (?: and '}' for ')', collapsing whitespace, we
|
|
// have this regular expression
|
|
$BOOLEAN = '(?:[<>~+-]?\((?:(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))+)\)|[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?\((?:(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))+)\)|[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))*';
|
|
|
|
// Require the use of at least one operator and conform to the
|
|
// boolean mode grammar
|
|
if (preg_match('`(^|\s)["()<>~+-]`u', $query, $T = array())
|
|
&& preg_match("`^{$BOOLEAN}$`u", $query, $T = array())
|
|
) {
|
|
// If using boolean operators, search in boolean mode. This regex
|
|
// will ensure proper placement of operators, whitespace, and quotes
|
|
// in an effort to avoid crashing the query at MySQL
|
|
$query = $this->quote($query);
|
|
$mode = ' IN BOOLEAN MODE';
|
|
}
|
|
#elseif (count(explode(' ', $query)) == 1)
|
|
# $mode = ' WITH QUERY EXPANSION';
|
|
$search = 'MATCH (Z1.title, Z1.content) AGAINST ('.db_input($query).$mode.')';
|
|
|
|
switch ($criteria->model) {
|
|
case false:
|
|
case 'Ticket':
|
|
if ($addRelevance) {
|
|
$criteria = $criteria->extra(array(
|
|
'select' => array(
|
|
'__relevance__' => 'Z1.`relevance`',
|
|
),
|
|
));
|
|
}
|
|
$criteria->extra(array(
|
|
'tables' => array(
|
|
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
|
|
"(SELECT COALESCE(Z3.`object_id`, Z5.`ticket_id`, Z8.`ticket_id`) as `ticket_id`, Z1.relevance FROM (SELECT Z1.`object_id`, Z1.`object_type`, {} AS `relevance` FROM `:_search` Z1 WHERE {} ORDER BY relevance DESC) Z1 LEFT JOIN `:thread_entry` Z2 ON (Z1.`object_type` = 'H' AND Z1.`object_id` = Z2.`id`) LEFT JOIN `:thread` Z3 ON (Z2.`thread_id` = Z3.`id` AND (Z3.`object_type` = 'T' OR Z3.`object_type` = 'C')) LEFT JOIN `:ticket` Z5 ON (Z1.`object_type` = 'T' AND Z1.`object_id` = Z5.`ticket_id`) LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') LEFT JOIN `:ticket` Z8 ON (Z8.`user_id` = Z6.`id`)) Z1"),
|
|
),
|
|
));
|
|
$criteria->extra(array('order_by' => array(array(new SqlCode('Z1.relevance', 'DESC')))));
|
|
|
|
$criteria->filter(array('ticket_id'=>new SqlCode('Z1.`ticket_id`')));
|
|
break;
|
|
|
|
case 'User':
|
|
$criteria->extra(array(
|
|
'select' => array(
|
|
'__relevance__' => 'Z1.`relevance`',
|
|
),
|
|
'tables' => array(
|
|
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
|
|
"(SELECT Z6.`id` as `user_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"),
|
|
)
|
|
));
|
|
$criteria->filter(array('id'=>new SqlCode('Z1.`user_id`')));
|
|
break;
|
|
|
|
case 'Organization':
|
|
$criteria->extra(array(
|
|
'select' => array(
|
|
'__relevance__' => 'Z1.`relevance`',
|
|
),
|
|
'tables' => array(
|
|
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
|
|
"(SELECT Z2.`id` as `org_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:organization` Z2 ON (Z2.`id` = Z1.`object_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"),
|
|
)
|
|
));
|
|
$criteria->filter(array('id'=>new SqlCode('Z1.`org_id`')));
|
|
break;
|
|
}
|
|
|
|
// TODO: Ensure search table exists;
|
|
if (false) {
|
|
// TODO: Create the search table automatically
|
|
// $class::createSearchTable();
|
|
}
|
|
return $criteria;
|
|
}
|
|
|
|
static function createSearchTable() {
|
|
// Use InnoDB with Galera, MyISAM with v5.5, and the database
|
|
// default otherwise
|
|
$sql = "select count(*) from information_schema.tables where
|
|
table_schema='information_schema' and table_name =
|
|
'INNODB_FT_CONFIG'";
|
|
$mysql56 = db_result(db_query($sql));
|
|
|
|
$sql = "show status like 'wsrep_local_state'";
|
|
$galera = db_result(db_query($sql));
|
|
|
|
if ($galera && !$mysql56)
|
|
throw new Exception('Galera cannot be used with MyISAM tables. Upgrade to MariaDB 10 / MySQL 5.6 is required');
|
|
$engine = $galera ? 'InnodB' : ($mysql56 ? '' : 'MyISAM');
|
|
if ($engine)
|
|
$engine = 'ENGINE='.$engine;
|
|
|
|
$sql = 'CREATE TABLE IF NOT EXISTS '.TABLE_PREFIX."_search (
|
|
`object_type` varchar(8) not null,
|
|
`object_id` int(11) unsigned not null,
|
|
`title` text collate utf8_general_ci,
|
|
`content` text collate utf8_general_ci,
|
|
primary key `object` (`object_type`, `object_id`),
|
|
fulltext key `search` (`title`, `content`)
|
|
) $engine CHARSET=utf8";
|
|
if (!db_query($sql))
|
|
return false;
|
|
|
|
// Start rebuilding the index
|
|
$config = new MySqlSearchConfig();
|
|
$config->set('reindex', 1);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Cooperates with the cron system to automatically find content that is
|
|
* not indexed in the _search table and add it to the index.
|
|
*/
|
|
function IndexOldStuff() {
|
|
$class = get_class();
|
|
$auto_create = function($db_error) use ($class) {
|
|
|
|
if ($db_error != 1146)
|
|
// Perform the standard error handling
|
|
return true;
|
|
|
|
// Create the search table automatically
|
|
$class::__init();
|
|
|
|
};
|
|
|
|
// THREADS ----------------------------------
|
|
$sql = "SELECT A1.`id`, A1.`title`, A1.`body`, A1.`format` FROM `".THREAD_ENTRY_TABLE."` A1
|
|
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='H')
|
|
WHERE A2.`object_id` IS NULL AND (A1.poster <> 'SYSTEM')
|
|
AND (IFNULL(LENGTH(A1.`title`), 0) + IFNULL(LENGTH(A1.`body`), 0) > 0)
|
|
ORDER BY A1.`id` DESC LIMIT 500";
|
|
if (!($res = db_query_unbuffered($sql, $auto_create)))
|
|
return false;
|
|
|
|
while ($row = db_fetch_row($res)) {
|
|
$body = ThreadEntryBody::fromFormattedText($row[2], $row[3]);
|
|
$body = $body->getSearchable();
|
|
$title = Format::searchable($row[1]);
|
|
if (!$body && !$title)
|
|
continue;
|
|
$record = array('H', $row[0], $title, $body);
|
|
if (!$this->__index($record))
|
|
return;
|
|
}
|
|
|
|
// TICKETS ----------------------------------
|
|
|
|
$sql = "SELECT A1.`ticket_id` FROM `".TICKET_TABLE."` A1
|
|
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`ticket_id` = A2.`object_id` AND A2.`object_type`='T')
|
|
WHERE A2.`object_id` IS NULL
|
|
ORDER BY A1.`ticket_id` DESC LIMIT 300";
|
|
if (!($res = db_query_unbuffered($sql, $auto_create)))
|
|
return false;
|
|
|
|
while ($row = db_fetch_row($res)) {
|
|
if (!($ticket = Ticket::lookup($row[0])))
|
|
continue;
|
|
$cdata = $ticket->loadDynamicData();
|
|
$content = array();
|
|
foreach ($cdata as $k=>$a)
|
|
if ($k != 'subject' && ($v = $a->getSearchable()))
|
|
$content[] = $v;
|
|
$record = array('T', $ticket->getId(),
|
|
Format::searchable($ticket->getNumber().' '.$ticket->getSubject()),
|
|
implode("\n", $content));
|
|
if (!$this->__index($record))
|
|
return;
|
|
}
|
|
|
|
// USERS ------------------------------------
|
|
|
|
$sql = "SELECT A1.`id` FROM `".USER_TABLE."` A1
|
|
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='U')
|
|
WHERE A2.`object_id` IS NULL";
|
|
if (!($res = db_query_unbuffered($sql, $auto_create)))
|
|
return false;
|
|
|
|
while ($row = db_fetch_row($res)) {
|
|
$user = User::lookup($row[0]);
|
|
$cdata = $user->getDynamicData();
|
|
$content = array();
|
|
foreach ($user->emails as $e)
|
|
$content[] = $e->address;
|
|
foreach ($cdata as $e)
|
|
foreach ($e->getAnswers() as $a)
|
|
if ($c = $a->getSearchable())
|
|
$content[] = $c;
|
|
$record = array('U', $user->getId(),
|
|
Format::searchable($user->getFullName()),
|
|
trim(implode("\n", $content)));
|
|
if (!$this->__index($record))
|
|
return;
|
|
}
|
|
|
|
// ORGANIZATIONS ----------------------------
|
|
|
|
$sql = "SELECT A1.`id` FROM `".ORGANIZATION_TABLE."` A1
|
|
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='O')
|
|
WHERE A2.`object_id` IS NULL";
|
|
if (!($res = db_query_unbuffered($sql, $auto_create)))
|
|
return false;
|
|
|
|
while ($row = db_fetch_row($res)) {
|
|
$org = Organization::lookup($row[0]);
|
|
$cdata = $org->getDynamicData();
|
|
$content = array();
|
|
foreach ($cdata as $e)
|
|
foreach ($e->getAnswers() as $a)
|
|
if ($c = $a->getSearchable())
|
|
$content[] = $c;
|
|
$record = array('O', $org->getId(),
|
|
Format::searchable($org->getName()),
|
|
trim(implode("\n", $content)));
|
|
if (!$this->__index($record))
|
|
return null;
|
|
}
|
|
|
|
// KNOWLEDGEBASE ----------------------------
|
|
|
|
require_once INCLUDE_DIR . 'class.faq.php';
|
|
$sql = "SELECT A1.`faq_id` FROM `".FAQ_TABLE."` A1
|
|
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`faq_id` = A2.`object_id` AND A2.`object_type`='K')
|
|
WHERE A2.`object_id` IS NULL";
|
|
if (!($res = db_query_unbuffered($sql, $auto_create)))
|
|
return false;
|
|
|
|
while ($row = db_fetch_row($res)) {
|
|
if (!($faq = FAQ::lookup($row[0])))
|
|
continue;
|
|
$q = $faq->getQuestion();
|
|
if ($k = $faq->getKeywords())
|
|
$q = $k.' '.$q;
|
|
$record = array('K', $faq->getId(),
|
|
Format::searchable($q),
|
|
$faq->getSearchableAnswer());
|
|
if (!$this->__index($record))
|
|
return;
|
|
}
|
|
|
|
// FILES ------------------------------------
|
|
|
|
// Flush non-full batch of records
|
|
$this->__index(null, true);
|
|
|
|
if (!$this->_reindexed) {
|
|
// Stop rebuilding the index
|
|
$this->getConfig()->set('reindex', 0);
|
|
}
|
|
}
|
|
|
|
function __index($record, $force_flush=false) {
|
|
static $queue = array();
|
|
|
|
if ($record)
|
|
$queue[] = $record;
|
|
elseif (!$queue)
|
|
return;
|
|
|
|
if (!$force_flush && count($queue) < $this::$BATCH_SIZE)
|
|
return true;
|
|
|
|
foreach ($queue as &$r)
|
|
$r = sprintf('(%s)', implode(',', db_input($r)));
|
|
unset($r);
|
|
|
|
$sql = 'INSERT INTO `'.TABLE_PREFIX.'_search` (`object_type`, `object_id`, `title`, `content`)
|
|
VALUES '.implode(',', $queue);
|
|
if (!db_query($sql, false) || count($queue) != db_affected_rows())
|
|
throw new Exception('Unable to index content');
|
|
|
|
$this->_reindexed += count($queue);
|
|
$queue = array();
|
|
|
|
if (!--$this->max_batches)
|
|
return null;
|
|
|
|
return true;
|
|
}
|
|
|
|
static function __init() {
|
|
self::createSearchTable();
|
|
}
|
|
|
|
}
|
|
|
|
Signal::connect('system.install',
|
|
array('MysqlSearchBackend', '__init'));
|
|
|
|
MysqlSearchBackend::register();
|
|
|
|
// Saved search system
|
|
|
|
/**
|
|
* Custom Queue truly represent a saved advanced search.
|
|
*/
|
|
class SavedQueue extends CustomQueue {
|
|
// Override the ORM relationship to force no children
|
|
private $children = false;
|
|
private $_config;
|
|
private $_criteria;
|
|
private $_columns;
|
|
private $_settings;
|
|
private $_form;
|
|
private $_sorts;
|
|
|
|
|
|
function __onload() {
|
|
global $thisstaff;
|
|
|
|
// Load custom settings for this staff
|
|
if ($thisstaff) {
|
|
$this->_config = QueueConfig::lookup(array(
|
|
'queue_id' => $this->getId(),
|
|
'staff_id' => $thisstaff->getId())
|
|
);
|
|
}
|
|
}
|
|
|
|
static function forStaff(Staff $agent) {
|
|
return static::objects()->filter(Q::any(array(
|
|
'staff_id' => $agent->getId(),
|
|
'flags__hasbit' => self::FLAG_PUBLIC,
|
|
)))
|
|
->exclude(array('flags__hasbit'=>self::FLAG_QUEUE));
|
|
}
|
|
|
|
private function getSettings() {
|
|
if (!isset($this->_settings)) {
|
|
$this->_settings = array();
|
|
if ($this->_config)
|
|
$this->_settings = $this->_config->getSettings();
|
|
}
|
|
|
|
return $this->_settings;
|
|
}
|
|
|
|
private function getCustomColumns() {
|
|
|
|
if (!isset($this->_columns)) {
|
|
$this->_columns = array();
|
|
if ($this->_config
|
|
&& $this->_config->columns->count())
|
|
$this->_columns = $this->_config->columns;
|
|
}
|
|
|
|
return $this->_columns;
|
|
}
|
|
|
|
static function getHierarchicalQueues(Staff $staff, $pid = 0, $primary = true) {
|
|
return CustomQueue::getHierarchicalQueues($staff, 0, false);
|
|
}
|
|
|
|
|
|
/*
|
|
* Determine if sort is inherited
|
|
*/
|
|
function isDefaultSortInherited() {
|
|
if ($this->parent
|
|
&& $this->getSettings()
|
|
&& @$this->_settings['inherit-sort'])
|
|
return true;
|
|
|
|
return parent::isDefaultSortInherited();
|
|
}
|
|
|
|
function getSortOptions() {
|
|
|
|
if (!isset($this->_sorts)) {
|
|
// See if the queue has sort options
|
|
if (($sorts=parent::getSortOptions()) && $sorts->count())
|
|
$this->_sorts = $sorts;
|
|
// otherwise return all sorts
|
|
else
|
|
$this->_sorts = QueueSort::objects();
|
|
}
|
|
|
|
return $this->_sorts;
|
|
}
|
|
|
|
function getDefaultSort() {
|
|
if ($this->getSettings()
|
|
&& $this->_settings['sort_id']
|
|
&& ($sort = QueueSort::lookup($this->_settings['sort_id'])))
|
|
return $sort;
|
|
|
|
return parent::getDefaultSort();
|
|
}
|
|
|
|
/**
|
|
* Fetch an AdvancedSearchForm instance for use in displaying or
|
|
* configuring this search in the user interface.
|
|
*
|
|
*/
|
|
function getForm($source=null, $searchable=array()) {
|
|
$searchable = null;
|
|
if ($this->isAQueue())
|
|
// Only allow supplemental matches.
|
|
$searchable = array_intersect_key($this->getCurrentSearchFields($source),
|
|
$this->getSupplementalMatches());
|
|
|
|
return parent::getForm($source, $searchable);
|
|
}
|
|
|
|
/**
|
|
* Get get supplemental matches for public queues.
|
|
*
|
|
*/
|
|
function getSupplementalMatches() {
|
|
// Target flags
|
|
$flags = array('isoverdue', 'isassigned', 'isreopened', 'isanswered');
|
|
$current = array();
|
|
// Check for closed state - whih disables above flags
|
|
foreach (parent::getCriteria() as $c) {
|
|
if (!strcasecmp($c[0], 'status__state')
|
|
&& isset($c[2]['closed']))
|
|
return array();
|
|
|
|
$current[] = $c[0];
|
|
}
|
|
|
|
// Filter out fields already in criteria
|
|
$matches = array_intersect_key($this->getSupportedMatches(),
|
|
array_flip(array_diff($flags, $current)));
|
|
|
|
return $matches;
|
|
}
|
|
|
|
function criteriaRequired() {
|
|
return !$this->isAQueue();
|
|
}
|
|
|
|
function describeCriteria($criteria=false){
|
|
$criteria = $criteria ?: parent::getCriteria();
|
|
return parent::describeCriteria($criteria);
|
|
}
|
|
|
|
function getCriteria($include_parent=true) {
|
|
|
|
if (!isset($this->_criteria)) {
|
|
$this->getSettings();
|
|
$this->_criteria = $this->_settings['criteria'] ?: array();
|
|
}
|
|
|
|
$criteria = $this->_criteria;
|
|
if ($include_parent)
|
|
$criteria = array_merge($criteria,
|
|
parent::getCriteria($include_parent));
|
|
|
|
|
|
return $criteria;
|
|
}
|
|
|
|
function getSupplementalCriteria() {
|
|
return $this->getCriteria(false);
|
|
}
|
|
|
|
function useStandardColumns() {
|
|
|
|
$this->getSettings();
|
|
if ($this->getCustomColumns()
|
|
&& isset($this->_settings['inherit-columns']))
|
|
return $this->_settings['inherit-columns'];
|
|
|
|
// owner?? edit away.
|
|
if ($this->_config
|
|
&& $this->_config->staff_id == $this->staff_id)
|
|
return false;
|
|
|
|
return parent::useStandardColumns();
|
|
}
|
|
|
|
function inheritColumns() {
|
|
if ($this->getSettings() && isset($this->_settings['inherit-columns']))
|
|
return $this->_settings['inherit-columns'];
|
|
|
|
return parent::inheritColumns();
|
|
}
|
|
|
|
function getStandardColumns() {
|
|
return parent::getColumns(is_null($this->parent));
|
|
}
|
|
|
|
function getColumns($use_template=false) {
|
|
|
|
if (!$this->useStandardColumns() && ($columns=$this->getCustomColumns()))
|
|
return $columns;
|
|
|
|
return parent::getColumns($use_template);
|
|
}
|
|
|
|
function update($vars, &$errors=array()) {
|
|
global $thisstaff;
|
|
|
|
if (!$this->checkAccess($thisstaff))
|
|
return false;
|
|
|
|
if ($this->checkOwnership($thisstaff)) {
|
|
// Owner of the queue - can update everything
|
|
if (!parent::update($vars, $errors))
|
|
return false;
|
|
|
|
// Personal queues _always_ inherit from their parent
|
|
$this->setFlag(self::FLAG_INHERIT_CRITERIA, $this->parent_id >
|
|
0);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Agent's config for public queue.
|
|
if (!$this->_config)
|
|
$this->_config = QueueConfig::create(array(
|
|
'queue_id' => $this->getId(),
|
|
'staff_id' => $thisstaff->getId()));
|
|
|
|
// Validate & isolate supplemental criteria (if any)
|
|
$vars['criteria'] = array();
|
|
if (isset($vars['fields'])) {
|
|
$form = $this->getForm($vars, $thisstaff);
|
|
if ($form->isValid()) {
|
|
$criteria = self::isolateCriteria($form->getClean(),
|
|
$this->getRoot());
|
|
$allowed = $this->getSupplementalMatches();
|
|
foreach ($criteria as $k => $c)
|
|
if (!isset($allowed[$c[0]]))
|
|
unset($criteria[$k]);
|
|
|
|
$vars['criteria'] = $criteria ?: array();
|
|
} else {
|
|
$errors['criteria'] = __('Validation errors exist on supplimental criteria');
|
|
}
|
|
}
|
|
|
|
if (!$errors && $this->_config->update($vars, $errors)) {
|
|
// reset settings
|
|
$this->_settings = $this->_criteria = null;
|
|
// Reset chached queue options
|
|
unset($_SESSION['sort'][$this->getId()]);
|
|
|
|
}
|
|
|
|
return (!$errors);
|
|
}
|
|
|
|
function getTotal($agent=null) {
|
|
$query = $this->getQuery();
|
|
if ($agent)
|
|
$query = $agent->applyVisibility($query);
|
|
$query->limit(false)->offset(false)->order_by(false);
|
|
try {
|
|
return $query->count();
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getCount($agent, $cached=true) {
|
|
$count = null;
|
|
if ($cached && ($counts = self::counts($agent, $cached)))
|
|
$count = $counts["q{$this->getId()}"];
|
|
|
|
if ($count == null)
|
|
$count = $this->getTotal($agent);
|
|
|
|
return $count;
|
|
}
|
|
|
|
// Get ticket counts for queues the agent has acces to.
|
|
static function counts($agent, $cached=true, $criteria=array()) {
|
|
|
|
if (!$agent instanceof Staff)
|
|
return null;
|
|
|
|
// Cache TLS in seconds
|
|
$ttl = 5*60;
|
|
// Cache key based on agent and salt of the installation
|
|
$key = "counts.queues.{$agent->getId()}.".SECRET_SALT;
|
|
if ($criteria && is_array($criteria)) // Consider additional criteria.
|
|
$key .= '.'.md5(serialize($criteria));
|
|
|
|
// only consider cache if requesed
|
|
if ($cached && ($counts=self::getCounts($key, $ttl)))
|
|
return $counts;
|
|
|
|
$queues = static::objects()
|
|
->filter(Q::any(array(
|
|
'flags__hasbit' => CustomQueue::FLAG_QUEUE,
|
|
'staff_id' => $agent->getId(),
|
|
)));
|
|
|
|
if ($criteria && is_array($criteria))
|
|
$queues->filter($criteria);
|
|
|
|
$counts = array();
|
|
$query = Ticket::objects();
|
|
// Apply tickets visibility for the agent
|
|
$query = $agent->applyVisibility($query, true);
|
|
// Aggregate constraints
|
|
foreach ($queues as $queue) {
|
|
$Q = $queue->getBasicQuery();
|
|
|
|
// only get counts for regular tickets (not children tickets) unless
|
|
// queue is a saved search
|
|
if ($queue->isAQueue() || $queue->isASubQueue()) {
|
|
$reg = Q::any(array('thread__object_type' => 'T'));
|
|
$Q->constraints[] = $reg;
|
|
}
|
|
|
|
if ($Q->constraints) {
|
|
$empty = false;
|
|
if (count($Q->constraints) > 1) {
|
|
foreach ($Q->constraints as $value) {
|
|
if (!$value->constraints)
|
|
$empty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add extra tables joins (if any)
|
|
if ($Q->extra && isset($Q->extra['tables'])) {
|
|
// skip counting keyword searches. Display them as '-'
|
|
$counts['q'.$queue->getId()] = '-';
|
|
continue;
|
|
$contraints = array();
|
|
if ($Q->constraints)
|
|
$constraints = new Q($Q->constraints);
|
|
foreach ($Q->extra['tables'] as $T)
|
|
$query->addExtraJoin(array($T, $constraints, ''));
|
|
}
|
|
|
|
if ($Q->constraints && !$empty) {
|
|
$expr = SqlCase::N()->when(new SqlExpr(new Q($Q->constraints)), new SqlField('ticket_id'));
|
|
$query->aggregate(array(
|
|
"q{$queue->id}" => SqlAggregate::COUNT($expr, true)
|
|
));
|
|
} else //display skipped counts as '-'
|
|
$counts['q'.$queue->getId()] = '-';
|
|
}
|
|
|
|
try {
|
|
$counts = array_merge($counts, $query->values()->one());
|
|
} catch (Exception $ex) {
|
|
foreach ($queues as $q)
|
|
$counts['q'.$q->getId()] = $q->getTotal();
|
|
}
|
|
|
|
// Always cache the results
|
|
self::storeCounts($key, $counts, $ttl);
|
|
|
|
return $counts;
|
|
}
|
|
|
|
static function getCounts($key, $ttl) {
|
|
|
|
if (!$key) {
|
|
return array();
|
|
} elseif (function_exists('apcu_store')) {
|
|
$found = false;
|
|
$counts = apcu_fetch($key, $found);
|
|
if ($found === true)
|
|
return $counts;
|
|
} elseif (isset($_SESSION['qcounts'][$key])
|
|
&& (time() - $_SESSION['qcounts'][$key]['time']) < $ttl) {
|
|
return $_SESSION['qcounts'][$key]['counts'];
|
|
} else {
|
|
// Auto clear missed session cache (if any)
|
|
unset($_SESSION['qcounts'][$key]);
|
|
}
|
|
}
|
|
|
|
static function storeCounts($key, $counts, $ttl) {
|
|
if (function_exists('apcu_store')) {
|
|
apcu_store($key, $counts, $ttl);
|
|
} else {
|
|
// Poor man's cache
|
|
$_SESSION['qcounts'][$key]['counts'] = $counts;
|
|
$_SESSION['qcounts'][$key]['time'] = time();
|
|
}
|
|
}
|
|
|
|
static function clearCounts() {
|
|
if (function_exists('apcu_store')) {
|
|
if (class_exists('APCUIterator')) {
|
|
$regex = '/^counts.queues.\d+.' . preg_quote(SECRET_SALT, '/') . '$/';
|
|
foreach (new APCUIterator($regex, APC_ITER_KEY) as $key) {
|
|
apcu_delete($key);
|
|
}
|
|
}
|
|
// Also clear rough counts
|
|
apcu_delete("rough.counts.".SECRET_SALT);
|
|
}
|
|
}
|
|
|
|
static function lookup($criteria) {
|
|
$queue = parent::lookup($criteria);
|
|
// Annoted cusom settings (if any)
|
|
if (($c=$queue->_config)) {
|
|
$queue->_settings = $c->getSettings() ?: array();
|
|
$queue = AnnotatedModel::wrap($queue,
|
|
array_intersect_key($queue->_settings,
|
|
array_flip(array('sort_id', 'filter'))));
|
|
$queue->_config = $c;
|
|
}
|
|
|
|
return $queue;
|
|
}
|
|
|
|
static function create($vars=false) {
|
|
$search = parent::create($vars);
|
|
$search->clearFlag(self::FLAG_QUEUE);
|
|
return $search;
|
|
}
|
|
}
|
|
|
|
class SavedSearch extends SavedQueue {
|
|
|
|
function isSaved() {
|
|
return (!$this->__new__);
|
|
}
|
|
|
|
function getCount($agent, $cached=true) {
|
|
return 500;
|
|
}
|
|
}
|
|
|
|
class AdhocSearch
|
|
extends SavedSearch {
|
|
|
|
function isSaved() {
|
|
return false;
|
|
}
|
|
|
|
function isOwner(Staff $staff) {
|
|
return $this->ht['staff_id'] == $staff->getId();
|
|
}
|
|
|
|
function checkAccess(Staff $staff) {
|
|
return true;
|
|
}
|
|
|
|
function getName() {
|
|
return $this->title ?: $this->describeCriteria();
|
|
}
|
|
|
|
function load($key) {
|
|
global $thisstaff;
|
|
|
|
if (strpos($key, 'adhoc') === 0)
|
|
list(, $key) = explode(',', $key, 2);
|
|
|
|
if (!$key
|
|
|| !isset($_SESSION['advsearch'])
|
|
|| !($config=$_SESSION['advsearch'][$key]))
|
|
return null;
|
|
|
|
$queue = new AdhocSearch(array(
|
|
'id' => "adhoc,$key",
|
|
'root' => 'T',
|
|
'staff_id' => $thisstaff->getId(),
|
|
'title' => __('Advanced Search'),
|
|
));
|
|
$queue->config = $config;
|
|
|
|
return $queue;
|
|
}
|
|
}
|
|
|
|
// AdvacedSearchForm
|
|
class AdvancedSearchForm extends SimpleForm {
|
|
static $id = 1337;
|
|
|
|
function getNumFieldsSelected() {
|
|
$selected = 0;
|
|
foreach ($this->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;
|
|
}
|
|
return $selected;
|
|
}
|
|
}
|
|
|
|
// Advanced search special fields
|
|
|
|
class AdvancedSearchSelectionField extends ChoiceField {
|
|
|
|
function hasIdValue() {
|
|
return false;
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
switch ($method) {
|
|
case 'includes':
|
|
case '!includes':
|
|
$Q = new Q();
|
|
if (count($value) > 1)
|
|
$Q->add(array("{$name}__in" => array_keys($value)));
|
|
else
|
|
$Q->add(array($name => key($value)));
|
|
|
|
if ($method == '!includes')
|
|
$Q->negate();
|
|
return $Q;
|
|
break;
|
|
// osTicket commonly uses `0` to represent an unset state, so
|
|
// the set and unset checks need to check for both not null and
|
|
// nonzero
|
|
case 'nset':
|
|
return new Q([$name => 0]);
|
|
case 'set':
|
|
return Q::not([$name => 0]);
|
|
default:
|
|
return parent::getSearchQ($method, $value, $name);
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
class HelpTopicChoiceField extends AdvancedSearchSelectionField {
|
|
static $_topics;
|
|
|
|
function hasIdValue() {
|
|
return true;
|
|
}
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
global $thisstaff;
|
|
if (!isset($this->_topics)) {
|
|
$this->_topics = $thisstaff ? $thisstaff->getTopicNames(false, Topic::DISPLAY_DISABLED) :
|
|
Topic::getHelpTopics(false, Topic::DISPLAY_DISABLED);;
|
|
}
|
|
|
|
return $this->_topics;
|
|
}
|
|
}
|
|
|
|
class SLAChoiceField extends AdvancedSearchSelectionField {
|
|
static $_slas;
|
|
|
|
function hasIdValue() {
|
|
return true;
|
|
}
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
if (!isset($this->_slas))
|
|
$this->_slas = SLA::getSLAs(array('nameOnly' => true));
|
|
|
|
return $this->_slas;
|
|
}
|
|
}
|
|
|
|
require_once INCLUDE_DIR . 'class.dept.php';
|
|
class DepartmentChoiceField extends AdvancedSearchSelectionField {
|
|
static $_depts;
|
|
static $_alldepts;
|
|
var $_choices;
|
|
|
|
function getDepts($criteria=array()) {
|
|
global $thisstaff;
|
|
|
|
$staff = $criteria['staff'];
|
|
$depts = array();
|
|
if ($staff)
|
|
foreach ($staff->getDepartmentNames(true) as $id => $name)
|
|
$depts[$id] = $name;
|
|
else
|
|
foreach (Dept::getDepartments() as $id => $name)
|
|
$depts[$id] = $name;
|
|
|
|
return $depts;
|
|
}
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
global $thisstaff;
|
|
$config = $this->getConfiguration();
|
|
|
|
$criteria = array(
|
|
'staff' => $config['staff'] ?: $thisstaff
|
|
);
|
|
if (!isset($this->_choices))
|
|
$this->_choices = $this->getDepts($criteria);
|
|
|
|
return $this->_choices;
|
|
|
|
}
|
|
|
|
function toString($value) {
|
|
if (!isset($this->_alldepts))
|
|
$this->_alldepts = $this->getDepts();
|
|
$choices = $this->_alldepts;
|
|
$selection = array();
|
|
if (!is_array($value))
|
|
$value = array($value => $value);
|
|
|
|
foreach ($value as $k => $v)
|
|
if (isset($choices[$k]))
|
|
$selection[] = $choices[$k];
|
|
|
|
return $selection ? implode(',', $selection) :
|
|
parent::toString($value);
|
|
}
|
|
|
|
function getQuickFilterChoices() {
|
|
global $thisstaff;
|
|
|
|
if (!isset($this->_choices)) {
|
|
$depts = $thisstaff ? $thisstaff->getDepts() : array();
|
|
foreach ($this->getChoices() as $id => $name) {
|
|
if (!$depts || in_array($id, $depts))
|
|
$this->_choices[$id] = $name;
|
|
}
|
|
}
|
|
|
|
return $this->_choices;
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'includes' => __('is'),
|
|
'!includes' => __('is not'),
|
|
);
|
|
}
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return $query->values('dept_id', 'dept__name');
|
|
}
|
|
|
|
function applyOrderBy($query, $reverse=false, $name=false) {
|
|
$reverse = $reverse ? '-' : '';
|
|
return $query->order_by("{$reverse}dept__name");
|
|
}
|
|
}
|
|
|
|
|
|
class AssigneeChoiceField extends ChoiceField {
|
|
|
|
protected $_items;
|
|
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
global $thisstaff;
|
|
|
|
if (!isset($this->_items)) {
|
|
$items = array(
|
|
'M' => __('Me'),
|
|
'T' => __('One of my teams'),
|
|
);
|
|
$assignees = Staff::getStaffMembers(array('staff' => $thisstaff));
|
|
|
|
foreach ($assignees as $id=>$name) {
|
|
// Don't include $thisstaff (since that's 'Me')
|
|
if ($thisstaff && $thisstaff->getId() == $id)
|
|
continue;
|
|
$items['s' . $id] = $name;
|
|
}
|
|
foreach (Team::getTeams() as $id=>$name) {
|
|
$items['t' . $id] = $name;
|
|
}
|
|
|
|
$this->_items = $items;
|
|
}
|
|
|
|
return $this->_items;
|
|
}
|
|
|
|
function getChoice($k) {
|
|
$choices = $this->getChoices();
|
|
return $choices[$k] ?: null;
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'assigned' => __('assigned'),
|
|
'!assigned' => __('unassigned'),
|
|
'includes' => __('includes'),
|
|
'!includes' => __('does not include'),
|
|
);
|
|
}
|
|
|
|
function getSearchMethodWidgets($options=array()) {
|
|
return array(
|
|
'assigned' => null,
|
|
'!assigned' => null,
|
|
'includes' => array('ChoiceField', array(
|
|
'choices' => $this->getChoices(false, $options),
|
|
'configuration' => array('multiselect' => true),
|
|
)),
|
|
'!includes' => array('ChoiceField', array(
|
|
'choices' => $this->getChoices(false, $options),
|
|
'configuration' => array('multiselect' => true),
|
|
)),
|
|
);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
global $thisstaff;
|
|
|
|
$Q = new Q();
|
|
switch ($method) {
|
|
case 'assigned':
|
|
$Q->negate();
|
|
case '!assigned':
|
|
$Q->add(array('team_id' => 0,
|
|
'staff_id' => 0));
|
|
break;
|
|
case '!includes':
|
|
$Q->negate();
|
|
case 'includes':
|
|
$teams = $agents = array();
|
|
$matches = count($value);
|
|
foreach ($value as $id => $ST) {
|
|
switch ($id[0]) {
|
|
case 'M':
|
|
$agents[] = $thisstaff->getId();
|
|
break;
|
|
case 's':
|
|
$agents[] = (int) substr($id, 1);
|
|
break;
|
|
case 'T':
|
|
if ($thisstaff && ($staffTeams = $thisstaff->getTeams()))
|
|
$teams = array_merge($staffTeams);
|
|
elseif ($matches == 1)
|
|
return Q::any(['team_id' => null]);
|
|
break;
|
|
case 't':
|
|
$teams[] = (int) substr($id, 1);
|
|
break;
|
|
}
|
|
}
|
|
$constraints = array();
|
|
if ($teams)
|
|
$constraints['team_id__in'] = $teams;
|
|
if ($agents)
|
|
$constraints['staff_id__in'] = $agents;
|
|
$Q->add(Q::any($constraints));
|
|
}
|
|
return $Q;
|
|
}
|
|
|
|
function describeSearchMethod($method) {
|
|
switch ($method) {
|
|
case 'assigned':
|
|
return __('assigned');
|
|
case '!assigned':
|
|
return __('unassigned');
|
|
default:
|
|
return parent::describeSearchMethod($method);
|
|
}
|
|
}
|
|
|
|
function addToQuery($query, $name=false) {
|
|
|
|
$fields = array();
|
|
foreach(Staff::getsortby('staff__') as $key)
|
|
$fields[] = new SqlField($key);
|
|
$fields[] = new SqlField('team__name');
|
|
$fields[] = 'zzz';
|
|
$expr = call_user_func_array(array('SqlFunction', 'COALESCE'), $fields);
|
|
$query->annotate(array($name ?: 'assignee' => $expr));
|
|
return $query->values('staff__firstname', 'staff__lastname', 'team__name', 'team_id');
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
if ($row['staff__firstname'])
|
|
return new AgentsName(array('first' => $row['staff__firstname'], 'last' => $row['staff__lastname']));
|
|
if ($row['team_id'])
|
|
return Team::getLocalById($row['team_id'], 'name', $row['team__name']);
|
|
|
|
}
|
|
|
|
function display($value) {
|
|
return (string) $value;
|
|
}
|
|
|
|
function toString($value) {
|
|
if (!is_array($value))
|
|
$value = array($value => $value);
|
|
$selection = array();
|
|
foreach ($value as $k => $v)
|
|
$selection[] = $this->getChoice($k) ?: (string) $v;
|
|
return implode(', ', $selection);
|
|
}
|
|
}
|
|
|
|
class AssignedField extends AssigneeChoiceField {
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
return array(
|
|
'assigned' => __('Assigned'),
|
|
'!assigned' => __('Unassigned'),
|
|
);
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'assigned' => __('assigned'),
|
|
'!assigned' => __('unassigned'),
|
|
);
|
|
}
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return $query->values('staff_id', 'team_id');
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return ($row['staff_id'] || $row['staff_id'])
|
|
? __('Yes') : __('No');
|
|
}
|
|
|
|
}
|
|
|
|
class MergedField extends FormField {
|
|
function getSearchMethods() {
|
|
return array(
|
|
'set' => __('checked'),
|
|
'nset' => __('unchecked'),
|
|
);
|
|
}
|
|
|
|
function addToQuery($query, $name=false) {
|
|
$query->annotate(array(
|
|
'merged' => new SqlExpr(new Q(array(
|
|
Q::any(array(
|
|
'flags__hasbit' => Ticket::FLAG_SEPARATE_THREADS,
|
|
'flags__hasbit' => Ticket::FLAG_COMBINE_THREADS,
|
|
)))
|
|
))));
|
|
|
|
return $query->values('merged');
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
global $thisstaff;
|
|
|
|
$Q = new Q();
|
|
switch ($method) {
|
|
case 'set':
|
|
$visibility = Q::any(array(
|
|
'flags__hasbit' => Ticket::FLAG_SEPARATE_THREADS,
|
|
));
|
|
$visibility->add(Q::any(array(
|
|
'flags__hasbit' => Ticket::FLAG_COMBINE_THREADS
|
|
)));
|
|
$visibility->ored = true;
|
|
return $visibility;
|
|
case 'nset':
|
|
$visibility = Q::all(array());
|
|
$visibility->add(Q::not(array(
|
|
'flags__hasbit' => Ticket::FLAG_SEPARATE_THREADS,
|
|
)));
|
|
$visibility->add(Q::not(array(
|
|
'flags__hasbit' => (Ticket::FLAG_COMBINE_THREADS)
|
|
)));
|
|
return $visibility;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
$flags = $row['flags'];
|
|
$combine = ($flags & Ticket::FLAG_COMBINE_THREADS) != 0;
|
|
$separate = ($flags & Ticket::FLAG_SEPARATE_THREADS) != 0;
|
|
return ($combine || $separate)
|
|
? __('Yes') : __('No');
|
|
}
|
|
}
|
|
|
|
class LinkedField extends FormField {
|
|
function getSearchMethods() {
|
|
return array(
|
|
'set' => __('checked'),
|
|
'nset' => __('unchecked'),
|
|
);
|
|
}
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return $query->values('ticket_pid', 'flags');
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
global $thisstaff;
|
|
|
|
$Q = new Q();
|
|
switch ($method) {
|
|
case 'set':
|
|
return Q::any(array(
|
|
'flags__hasbit' => Ticket::FLAG_LINKED,
|
|
));
|
|
case 'nset':
|
|
return Q::not(array(
|
|
'flags__hasbit' => Ticket::FLAG_LINKED,));
|
|
break;
|
|
}
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
$flags = $row['flags'];
|
|
$linked = ($flags & Ticket::FLAG_LINKED) != 0;
|
|
return ($linked)
|
|
? __('Yes') : __('No');
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Simple trait which changes the SQL for "has a value" and "does not have a
|
|
* value" to check for zero or non-zero. Useful for not nullable fields.
|
|
*/
|
|
trait ZeroMeansUnset {
|
|
function getSearchQ($method, $value, $name=false) {
|
|
$name = $name ?: $this->get('name');
|
|
switch ($method) {
|
|
// osTicket commonly uses `0` to represent an unset state, so
|
|
// the set and unset checks need to check for both not null and
|
|
// nonzero
|
|
case 'nset':
|
|
return new Q([$name => 0]);
|
|
case 'set':
|
|
return Q::not([$name => 0]);
|
|
}
|
|
return parent::getSearchQ($method, $value, $name);
|
|
}
|
|
}
|
|
|
|
class AgentSelectionField extends AdvancedSearchSelectionField {
|
|
use ZeroMeansUnset;
|
|
static $_allagents;
|
|
static $_agents;
|
|
|
|
function getAgents($criteria=array()) {
|
|
$dept = $criteria['dept'] ?: null;
|
|
$staff = $criteria['staff'] ?: null;
|
|
$agents = array();
|
|
if ($dept) {
|
|
foreach ($dept->getAssignees(array('staff' => $staff)) as $a)
|
|
$agents[$a->getId()] = $a;
|
|
} else {
|
|
foreach (Staff::getStaffMembers(array('staff' => $staff)) as $id => $name) {
|
|
if ($staff && $staff->getId() == $id)
|
|
$agents['M'] = __('Me');
|
|
$agents[$id] = $name;
|
|
}
|
|
}
|
|
return $agents;
|
|
}
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
global $thisstaff;
|
|
$config = $this->getConfiguration();
|
|
$criteria = array(
|
|
'dept' => $config['dept'] ?: null,
|
|
'staff' => $config['staff'] ?: $thisstaff
|
|
);
|
|
if (!isset($this->_choices))
|
|
$this->_choices = $this->getAgents($criteria);
|
|
|
|
return $this->_choices;
|
|
|
|
}
|
|
|
|
function toString($value) {
|
|
if (!isset($this->_allagents))
|
|
$this->_allagents = $this->getAgents();
|
|
$choices = $this->_allagents;
|
|
$selection = array();
|
|
if (!is_array($value))
|
|
$value = array($value => $value);
|
|
|
|
foreach ($value as $k => $v)
|
|
if (isset($choices[$k]))
|
|
$selection[] = $choices[$k];
|
|
|
|
return $selection ? implode(',', $selection) :
|
|
parent::toString($value);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
global $thisstaff;
|
|
// unpack me
|
|
if (isset($value['M']) && $thisstaff) {
|
|
$value[$thisstaff->getId()] = $thisstaff->getName();
|
|
unset($value['M']);
|
|
}
|
|
|
|
return parent::getSearchQ($method, $value, $name);
|
|
}
|
|
|
|
function getSortKeys($path='') {
|
|
return Staff::getsortby('staff__');
|
|
}
|
|
|
|
function applyOrderBy($query, $reverse=false, $name=false) {
|
|
$reverse = $reverse ? '-' : '';
|
|
return Staff::nsort($query, "{$reverse}staff__");
|
|
}
|
|
}
|
|
|
|
class DepartmentManagerSelectionField extends AgentSelectionField {
|
|
static $_members;
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
global $thisstaff;
|
|
|
|
if (!isset($this->_members)) {
|
|
$managers = array();
|
|
$mgr = Dept::objects()->filter(array('manager_id__gt' => 0))->values_flat('manager_id');
|
|
$staff = $thisstaff->getDeptAgents(array('available' => true, 'namesOnly' => true));
|
|
foreach ($mgr as $mid) {
|
|
$mid = $mid[0];
|
|
if (array_key_exists($mid, $staff))
|
|
$managers['s'.$mid] = $staff[$mid]->getName()->name;
|
|
}
|
|
$this->_members = $managers;
|
|
}
|
|
|
|
return $this->_members;
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
return parent::getSearchQ($method, $value, 'dept__manager_id');
|
|
}
|
|
}
|
|
|
|
class TeamSelectionField extends AdvancedSearchSelectionField {
|
|
static $_teams;
|
|
|
|
function getChoices($verbose=false, $options=array()) {
|
|
if (!isset($this->_teams) && $teams = Team::getTeams())
|
|
$this->_teams = array('T' => __('One of my teams')) +
|
|
$teams;
|
|
|
|
return $this->_teams;
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
global $thisstaff;
|
|
|
|
// Unpack my teams
|
|
if (isset($value['T'])) {
|
|
if (!$thisstaff || !($teams = $thisstaff->getTeams()))
|
|
return Q::any(['team_id' => null]);
|
|
|
|
unset($value['T']);
|
|
$value = $value + array_flip($teams);
|
|
}
|
|
return parent::getSearchQ($method, $value, $name);
|
|
}
|
|
|
|
function getSortKeys($path) {
|
|
return array('team__name');
|
|
}
|
|
|
|
function applyOrderBy($query, $reverse=false, $name=false) {
|
|
$reverse = $reverse ? '-' : '';
|
|
return $query->order_by("{$reverse}team__name");
|
|
}
|
|
|
|
function toString($value) {
|
|
$choices = $this->getChoices();
|
|
$selection = array();
|
|
if (!is_array($value))
|
|
$value = array($value => $value);
|
|
foreach ($value as $k => $v)
|
|
if (isset($choices[$k]))
|
|
$selection[] = $choices[$k];
|
|
return $selection ? implode(',', $selection) :
|
|
parent::toString($value);
|
|
}
|
|
|
|
}
|
|
|
|
class TicketStateChoiceField extends AdvancedSearchSelectionField {
|
|
function getChoices($verbose=false, $options=array()) {
|
|
return array(
|
|
'open' => __('Open'),
|
|
'closed' => __('Closed'),
|
|
'archived' => _P('ticket state name', 'Archived'),
|
|
'deleted' => _P('ticket state name','Deleted'),
|
|
);
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'includes' => __('is'),
|
|
'!includes' => __('is not'),
|
|
);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
return parent::getSearchQ($method, $value, 'status__state');
|
|
}
|
|
}
|
|
|
|
class TicketFlagChoiceField extends ChoiceField {
|
|
function getChoices($verbose=false, $options=array()) {
|
|
return array(
|
|
'isanswered' => __('Answered'),
|
|
'isoverdue' => __('Overdue'),
|
|
);
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'includes' => __('is'),
|
|
'!includes' => __('is not'),
|
|
);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
$Q = new Q();
|
|
if (isset($value['isanswered']))
|
|
$Q->add(array('isanswered' => 1));
|
|
if (isset($value['isoverdue']))
|
|
$Q->add(array('isoverdue' => 1));
|
|
if ($method == '!includes')
|
|
$Q->negate();
|
|
if ($Q->constraints)
|
|
return $Q;
|
|
}
|
|
}
|
|
|
|
class TicketSourceChoiceField extends ChoiceField {
|
|
function getChoices($verbose=false, $options=array()) {
|
|
return Ticket::getSources();
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'includes' => __('is'),
|
|
'!includes' => __('is not'),
|
|
);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
return parent::getSearchQ($method, $value, 'source');
|
|
}
|
|
}
|
|
|
|
class OpenClosedTicketStatusList extends TicketStatusList {
|
|
function getItems($criteria=array()) {
|
|
$rv = array();
|
|
$base = parent::getItems($criteria);
|
|
foreach ($base as $idx=>$S) {
|
|
if (in_array($S->state, array('open', 'closed')))
|
|
$rv[$idx] = $S;
|
|
}
|
|
return $rv;
|
|
}
|
|
}
|
|
|
|
class TicketStatusChoiceField extends SelectionField {
|
|
static $widget = 'ChoicesWidget';
|
|
|
|
function getList() {
|
|
return new OpenClosedTicketStatusList(
|
|
DynamicList::lookup(
|
|
array('type' => 'ticket-status'))
|
|
);
|
|
}
|
|
|
|
function getSearchMethods() {
|
|
return array(
|
|
'includes' => __('is'),
|
|
'!includes' => __('is not'),
|
|
);
|
|
}
|
|
|
|
function getSearchQ($method, $value, $name=false) {
|
|
$name = $name ?: $this->get('name');
|
|
if (!$value)
|
|
return false;
|
|
switch ($method) {
|
|
case '!includes':
|
|
return Q::not(array("{$name}__in" => array_keys($value)));
|
|
case 'includes':
|
|
return new Q(array("{$name}__in" => array_keys($value)));
|
|
default:
|
|
return parent::getSearchQ($method, $value, $name);
|
|
}
|
|
}
|
|
|
|
function applyOrderBy($query, $reverse=false, $name=false) {
|
|
$reverse = $reverse ? '-' : '';
|
|
return $query->order_by("{$reverse}status__name");
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Implemented by annotated fields
|
|
*
|
|
*/
|
|
|
|
interface AnnotatedField {
|
|
// Add the annotation to a QuerySet
|
|
function annotate($query, $name);
|
|
}
|
|
|
|
class TicketThreadCountField extends NumericField
|
|
implements AnnotatedField {
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return TicketThreadCount::addToQuery($query, $name);
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return TicketThreadCount::from_query($row, $name);
|
|
}
|
|
|
|
function annotate($query, $name) {
|
|
return TicketThreadCount::annotate($query, $name);
|
|
}
|
|
}
|
|
|
|
class TicketReopenCountField extends NumericField
|
|
implements AnnotatedField {
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return TicketReopenCount::addToQuery($query, $name);
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return TicketReopenCount::from_query($row, $name);
|
|
}
|
|
|
|
function annotate($query, $name) {
|
|
return TicketReopenCount::annotate($query, $name);
|
|
}
|
|
}
|
|
|
|
class ThreadAttachmentCountField extends NumericField
|
|
implements AnnotatedField {
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return ThreadAttachmentCount::addToQuery($query, $name);
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return ThreadAttachmentCount::from_query($row, $name);
|
|
}
|
|
|
|
function annotate($query, $name) {
|
|
return ThreadAttachmentCount::annotate($query, $name);
|
|
}
|
|
}
|
|
|
|
class ThreadCollaboratorCountField extends NumericField
|
|
implements AnnotatedField {
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return ThreadCollaboratorCount::addToQuery($query, $name);
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return ThreadCollaboratorCount::from_query($row, $name);
|
|
}
|
|
|
|
function annotate($query, $name) {
|
|
return ThreadCollaboratorCount::annotate($query, $name);
|
|
}
|
|
}
|
|
|
|
class TicketTasksCountField extends NumericField
|
|
implements AnnotatedField {
|
|
|
|
function addToQuery($query, $name=false) {
|
|
return TicketTasksCount::addToQuery($query, $name);
|
|
}
|
|
|
|
function from_query($row, $name=false) {
|
|
return TicketTasksCount::from_query($row, $name);
|
|
}
|
|
|
|
function annotate($query, $name) {
|
|
return TicketTasksCount::annotate($query, $name);
|
|
}
|
|
}
|
|
|
|
interface Searchable {
|
|
// Fetch an array of [ orm__path => Field() ] pairs. The field label is
|
|
// used when this list is rendered in a dropdown, and the field search
|
|
// mechanisms are use to apply query filtering based on the field.
|
|
static function getSearchableFields();
|
|
|
|
// Determine if the object supports abritrary form additions, through
|
|
// the "Manage Forms" dialog usually
|
|
static function supportsCustomData();
|
|
}
|