Skip to content
Snippets Groups Projects
class.search.php 32.6 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?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;
    
    
        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);
    
        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) {
    
            $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(),
    
                        //TODO: send attribute of object_id
                        'ticket_id' =>  $model->getThread()->getObjectId(),
    
                        '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('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'));
    
        }
    }
    
    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()) {
    
    
    
            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;
    
            $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)
    
            $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(
    
                        'ticket_id' => $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 A3.id as thread_id, A1.ticket_id from {$P}ticket A1
                        join {$P}thread A2 on (A1.ticket_id = A2.object_id and A2.object_type = 'T')
                        join {$P}thread_entry A3 on (A2.id = A3.thread_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) {
    
    Peter Rotich's avatar
    Peter Rotich committed
                // 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
    
            };
    
            // THREADS ----------------------------------
    
    Peter Rotich's avatar
    Peter Rotich committed
            $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 (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)) {
    
    Peter Rotich's avatar
    Peter Rotich committed
                $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";
            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;
    
                    $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;
    
        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'),