-
Jared Hancock authoredJared Hancock authored
class.search.php 32.09 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:
**********************************************************************/
abstract class SearchBackend {
static $id = false;
static $registry = array();
const SORT_RELEVANCE = 1;
const SORT_RECENT = 2;
const SORT_OLDEST = 3;
abstract function update($model, $id, $content, $new=false, $attrs=array());
abstract function find($query, QuerySet $criteria);
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]();
}
}
// Register signals to intercept saving of various content throughout the
// system
class SearchInterface {
var $backend;
function __construct() {
$this->bootstrap();
}
function find($query, QuerySet $criteria) {
$query = Format::searchable($query);
return $this->backend->find($query, $criteria);
}
function update($model, $id, $content, $new=false, $attrs=array()) {
if (!$this->backend)
return;
$this->backend->update($model, $id, $content, $new, $attrs);
}
function createModel($model) {
return $this->updateModel($model, true);
}
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,
array(
'title' => $model->getTitle(),
'ticket_id' => $model->getTicketId(),
'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,
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,
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,
array(
'title'=> Format::searchable($model->getName()),
'created'=> $model->getCreateDate(),
)
);
break;
case $model instanceof FAQ:
$this->update($model, $model->getId(),
$model->getSearchableAnswer(),
$new,
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('model.created', array($this, 'createModel'));
Signal::connect('model.updated', array($this, 'updateModel'));
#Signal::connect('model.deleted', array($this, 'deleteModel'));
}
}
class MysqlSearchBackend extends SearchBackend {
static $id = 'mysql';
static $BATCH_SIZE = 30;
// Only index 20 batches per cron run
var $max_batches = 60;
function __construct() {
$this->SEARCH_TABLE = TABLE_PREFIX . '_search';
}
function bootstrap() {
Signal::connect('cron', array($this, 'IndexOldStuff'));
}
function update($model, $id, $content, $new=false, $attrs=array()) {
switch (true) {
case $model instanceof ThreadEntry:
$type = 'H';
break;
case $model instanceof Ticket:
$attrs['title'] = $attrs['number'].' '.$attrs['title'];
$type = 'T';
break;
case $model instanceof User:
$content .= implode("\n", $attrs['emails']);
$type = 'U';
break;
case $model instanceof Organization:
$type = 'O';
break;
case $model instanceof FAQ:
$type = 'K';
break;
case $model instanceof AttachmentFile:
$type = 'F';
break;
default:
// Not indexed
return;
}
$title = $attrs['title'] ?: '';
if (!$content && !$title)
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);
}
// Quote things like email addresses
function quote($query) {
$parts = array();
if (!preg_match_all('`([^\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];
}
return implode(' ', $results);
}
function find($query, QuerySet $criteria) {
global $thisstaff;
$mode = ' IN BOOLEAN MODE';
#if (count(explode(' ', $query)) == 1)
# $mode = ' WITH QUERY EXPANSION';
$query = $this->quote($query);
$search = 'MATCH (search.title, search.content) AGAINST ('
.db_input($query)
.$mode.')';
$tables = array();
$P = TABLE_PREFIX;
$sort = '';
switch ($criteria->model) {
case false:
case 'TicketModel':
if ($query) {
$key = 'COALESCE(Z1.ticket_id, Z2.ticket_id)';
$criteria->extra(array(
'select' => array(
'key' => $key,
'relevance'=>'`search`.`relevance`',
),
'order_by' => array(new SqlCode('`relevance`')),
'tables' => array(
"(SELECT object_type, object_id, $search AS `relevance`
FROM `{$P}_search` `search` WHERE $search) `search`",
"(select ticket_id as ticket_id from {$P}ticket
) Z1 ON (Z1.ticket_id = search.object_id and search.object_type = 'T')",
"(select A2.id as thread_id, A1.ticket_id from {$P}ticket A1
join {$P}ticket_thread A2 on (A1.ticket_id = A2.ticket_id)
) Z2 ON (Z2.thread_id = search.object_id and search.object_type = 'H')",
)
));
// XXX: This is extremely ugly
$criteria->filter(array('ticket_id'=>new SqlCode($key)));
$criteria->distinct('ticket_id');
}
// TODO: Consider sorting preferences
}
// 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');
$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";
return db_query($sql);
}
/**
* Cooperates with the cron system to automatically find content that is
* not index 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 `".TICKET_THREAD_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 (LENGTH(A1.`title`) + LENGTH(A1.`body`) > 0)
ORDER BY A1.`id` DESC";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$body = ThreadBody::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";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$ticket = Ticket::lookup($row[0]);
$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
ORDER BY A1.`id` DESC";
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
ORDER BY A1.`id` DESC";
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
ORDER BY A1.`faq_id` DESC";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$faq = FAQ::lookup($row[0]);
$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);
}
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) || count($queue) != db_affected_rows())
throw new Exception('Unable to index content');
$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
/**
*
* 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
*/
class SavedSearch extends VerySimpleModel {
static $meta = array(
'table' => 'ost_queue', # QUEUE_TABLE
'pk' => array('id'),
'ordering' => array('sort'),
);
const FLAG_PUBLIC = 0x0001;
const FLAG_QUEUE = 0x0002;
static function forStaff(Staff $agent) {
return static::objects()->filter(Q::any(array(
'staff_id' => $agent->getId(),
'flags__hasbit' => self::FLAG_PUBLIC,
)));
}
function getFormFromSession($key, $source=false) {
if (isset($_SESSION[$key])) {
$source = $source ?: array();
$state = $_SESSION[$key];
// Pull out 'other' fields from the state so the fields will be
// added to the form. The state will be loaded below
foreach ($state as $k=>$v) {
$info = array();
if (!preg_match('/^:(\w+)!(\d+)\+search/', $k, $info)) {
continue;
}
list($k,) = explode('+', $k, 2);
$source['fields'][] = ":{$info[1]}!{$info[2]}";
}
}
return $this->getForm($source);
}
function getForm($source=false) {
// XXX: Ensure that the UIDs generated for these fields are
// consistent between requests
$searchable = $this->getCurrentSearchFields($source);
$fields = array(
'keywords' => new TextboxField(array(
'configuration' => array(
'size' => 40,
'length' => 400,
'classes' => 'full-width headline',
'placeholder' => __('Keywords — Optional'),
),
)),
);
foreach ($searchable as $name=>$field) {
$fields = array_merge($fields, self::getSearchField($field, $name));
}
$form = new Form($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'));
});
return $form;
}
function getCurrentSearchFields($source=false) {
$core = array(
'state' => new TicketStateChoiceField(array(
'label' => __('State'),
)),
'status_id' => new TicketStatusChoiceField(array(
'label' => __('Status'),
)),
'flags' => new TicketFlagChoiceField(array(
'label' => __('Flags'),
)),
'dept_id' => new DepartmentChoiceField(array(
'label' => __('Department'),
)),
'assignee' => new AssigneeChoiceField(array(
'label' => __('Assignee'),
)),
'topic_id' => new HelpTopicChoiceField(array(
'label' => __('Help Topic'),
)),
'created' => new DateTimeField(array(
'label' => __('Created'),
)),
'duedate' => new DateTimeField(array(
'label' => __('Due Date'),
)),
);
// Add 'other' fields added dynamically
if (is_array($source) && isset($source['fields'])) {
foreach ($source['fields'] as $f) {
$info = array();
if (!preg_match('/^:(\w+)!(\d+)/', $f, $info)) {
continue;
}
$id = $info[2];
if (is_numeric($id) && ($field = DynamicFormField::lookup($id))) {
$impl = $field->getImpl();
$impl->set('label', sprintf('%s / %s',
$field->form->getLocal('title'), $field->getLocal('label')
));
$core[":{$info[1]}!{$info[2]}"] = $impl;
}
}
}
return $core;
}
static function getSearchField($field, $name) {
$pieces = array();
$pieces["{$name}+search"] = new BooleanField(array(
'configuration' => array('desc' => $field->get('label'))
));
$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),
));
foreach ($field->getSearchMethodWidgets() as $m=>$w) {
if (!$w)
continue;
list($class, $args) = $w;
$args['required'] = true;
$args['visibility'] = new VisibilityConstraint(new Q(array(
"{$name}+method__eq" => $m,
)), VisibilityConstraint::HIDDEN);
$pieces["{$name}+{$m}"] = new $class($args);
}
return $pieces;
}
function mangleQuerySet(QuerySet $qs, $form=false) {
$form = $form ?: $this->getForm();
$searchable = $this->getCurrentSearchFields($form->getSource());
// Figure out fields to search on
foreach ($form->getFields() as $f) {
if (substr($f->get('name'), -7) == '+search' && $f->getClean()) {
$name = substr($f->get('name'), 0, -7);
// Determine the search method and fetch the original field
if (($M = $form->getField("{$name}+method"))
&& ($method = $M->getClean())
&& ($field = $searchable[$name])
) {
// Request the field to generate a search Q for the
// search method and given value
$value = null;
if ($value = $form->getField("{$name}+{$method}"))
$value = $value->getClean();
if ($name[0] == ':') {
// This was an 'other' field, fetch a special "name"
// for it which will be the ORM join path
static $other_paths = array(
':ticket' => 'cdata__',
':user' => 'user__cdata__',
':organization' => 'user__org__cdata__',
);
$column = $field->get('name') ?: 'field_'.$field->get('id');
list($type,$id) = explode('!', $name, 2);
$OP = $other_paths[$type];
if ($type == ':field') {
$DF = DynamicFormField::lookup($id);
TicketModel::registerCustomData($DF->form);
$OP = 'cdata+'.$DF->form->id.'__';
}
// XXX: Last mile — find a better idea
switch (array($type, $column)) {
case array(':user', 'name'):
$name = 'user__name';
break;
case array(':user', 'email'):
$name = 'user__emails__address';
break;
case array(':organization', 'name'):
$name = 'user__org__name';
break;
default:
$name = $OP . $column;
}
}
// Add the criteria to the QuerySet
if ($Q = $field->getSearchQ($method, $value, $name))
$qs = $qs->filter($Q);
}
}
}
// Consider keyword searching
if ($keywords = $form->getField('keywords')->getClean()) {
global $ost;
$qs = $ost->searcher->find($keywords, $qs);
}
return $qs;
}
function checkAccess(Staff $agent) {
return $agent->getId() == $this->staff_id
|| $this->hasFlag(self::FLAG_PUBLIC);
}
protected function hasFlag($flag) {
return $this->get('flag') & $flag !== 0;
}
protected function clearFlag($flag) {
return $this->set('flag', $this->get('flag') & ~$flag);
}
protected function setFlag($flag) {
return $this->set('flag', $this->get('flag') | $flag);
}
static function create($vars=array()) {
$inst = parent::create($vars);
$inst->created = SqlFunction::NOW();
return $inst;
}
function save($refetch=false) {
if ($this->dirty)
$this->updated = SqlFunction::NOW();
return parent::save($refetch || $this->dirty);
}
}
// Advanced search special fields
class HelpTopicChoiceField extends ChoiceField {
function hasIdValue() {
return true;
}
function getChoices() {
return Topic::getHelpTopics(false, Topic::DISPLAY_DISABLED);
}
}
require_once INCLUDE_DIR . 'class.dept.php';
class DepartmentChoiceField extends ChoiceField {
function getChoices() {
return Dept::getDepartments();
}
function getSearchMethods() {
return array(
'includes' => __('is'),
'!includes' => __('is not'),
);
}
}
class AssigneeChoiceField extends ChoiceField {
function getChoices() {
$items = array(
'M' => __('Me'),
'T' => __('One of my teams'),
);
foreach (Staff::getStaffMembers() as $id=>$name) {
$items['s' . $id] = $name;
}
foreach (Team::getTeams() as $id=>$name) {
$items['t' . $id] = $name;
}
return $items;
}
function getSearchMethods() {
return array(
'assigned' => __('assigned'),
'!assigned' => __('unassigned'),
'includes' => __('includes'),
'!includes' => __('does not include'),
);
}
function getSearchMethodWidgets() {
return array(
'assigned' => null,
'!assigned' => null,
'includes' => array('ChoiceField', array(
'choices' => $this->getChoices(),
'configuration' => array('multiselect' => true),
)),
'!includes' => array('ChoiceField', array(
'choices' => $this->getChoices(),
'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();
foreach ($value as $id => $ST) {
switch ($id[0]) {
case 'M':
$agents[] = $thisstaff->getId();
break;
case 's':
$agents[] = (int) substr($id, 1);
break;
case 'T':
$teams = array_merge($thisstaff->getTeams());
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;
}
}
class TicketStateChoiceField extends ChoiceField {
function getChoices() {
return array(
'open' => __('Open'),
'closed' => __('Closed'),
'archived' => __('Archived'),
'deleted' => __('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() {
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 TicketStatusChoiceField extends SelectionField {
static $widget = 'ChoicesWidget';
function getList() {
return new TicketStatusList(
DynamicList::lookup(
array('type' => 'ticket-status'))
);
}
function getSearchMethods() {
return array(
'includes' => __('is'),
'!includes' => __('is not'),
);
}
}