Newer
Older
<?php
/*********************************************************************
class.queue.php
Custom (ticket) queues for osTicket
Jared Hancock <jared@osticket.com>
Peter Rotich <peter@osticket.com>
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.search.php';
class CustomQueue extends SavedSearch {
static $meta = array(
'select_related' => array('parent'),
'joins' => array(
'columns' => array(
'reverse' => 'QueueColumn.queue',
),
'staff' => array(
'constraint' => array(
'staff_id' => 'Staff.staff_id',
)
),
'parent' => array(
'constraint' => array(
'parent_id' => 'CustomQueue.id',
),
'null' => true,
),
'children' => array(
'reverse' => 'CustomQueue.parent',
return parent::objects()->filter(array(
'flags__hasbit' => static::FLAG_QUEUE
));
}
static function getAnnotations($root) {
// Ticket annotations
return array(
'TicketThreadCount',
'ThreadAttachmentCount',
'OverdueFlagDecoration',
'TicketSourceDecoration'
);
}
function getColumns() {
if (!count($this->columns)) {
foreach (parent::getColumns() as $c)
$this->addColumn($c);
}
return $this->columns;
}
function addColumn(QueueColumn $col) {
$this->columns->add($col);
$col->queue = $this;
}
function getStatus() {
return 'bogus';
}
function getChildren() {
return $this->children;
}
function getPublicChildren() {
return $this->children->findAll(array(
));
}
function getMyChildren() {
global $thisstaff;
if (!$thisstaff instanceof Staff)
return array();
return $this->children->findAll(array(
'staff_id' => $thisstaff->getId(),
Q::not(array(
'flags__hasbit' => self::FLAG_PUBLIC
))
));
}
function buildPath() {
if (!$this->id)
return;
$path = $this->parent ? $this->parent->getPath() : '';
return $path . "/{$this->id}";
}
function getFullName() {
$base = $this->getName();
if ($this->parent)
$base = sprintf("%s / %s", $this->parent->getFullName(), $base);
return $base;
}
function inheritCriteria() {
return $this->flags & self::FLAG_INHERIT_CRITERIA;
}
function getBasicQuery($form=false) {
if ($this->parent && $this->inheritCriteria()) {
$query = $this->parent->getBasicQuery();
}
else {
$root = $this->getRoot();
$query = $root::objects();
}
return $this->mangleQuerySet($query);
}
/**
* Retrieve a QuerySet instance based on the type of object (root) of
* this Q, which is automatically configured with the data and criteria
* of the queue and its columns.
*
* Returns:
* <QuerySet> instance
*/
function getQuery($form=false, $quick_filter=null) {
$query = $this->getBasicQuery($form);
// Apply quick filter
if (isset($quick_filter)
&& ($qf = $this->getQuickFilterField($quick_filter))
) {
$this->filter = @SavedSearch::getOrmPath($this->filter, $query);
$query = $qf->applyQuickFilter($query, $quick_filter,
$this->filter);
}
// Apply column, annotations and conditions additions
foreach ($this->getColumns() as $C) {
$query = $C->mangleQuery($query);
}
return $query;
}
function getQuickFilterField($value=null) {
if ($this->filter == '::') {
if ($this->parent) {
return $this->parent->getQuickFilterField($value);
}
}
elseif ($this->filter
&& ($fields = SavedSearch::getSearchableFields($this->getRoot()))
&& (list(,$f) = @$fields[$this->filter])
&& $f->supportsQuickFilter()
) {
$f->value = $value;
return $f;
}
}
function update($vars, &$errors=array()) {
// TODO: Move this to SavedSearch::update() and adjust
// AjaxSearch::_saveSearch()
$form = $this->getForm($vars);
if (!$vars || !$form->isValid()) {
$errors['criteria'] = __('Validation errors exist on criteria');
}
else {
$this->config = JsonDataEncoder::encode(
$this->isolateCriteria($form->getClean()));
}
// Set basic queue information
$this->title = $vars['name'];
$this->parent_id = $vars['parent_id'];
$this->path = $this->buildPath();
$this->setFlag(self::FLAG_INHERIT_CRITERIA, isset($vars['inherit']));
// Update queue columns (but without save)
if (isset($vars['columns'])) {
$new = $vars['columns'];
foreach ($this->columns as $col) {
if (false === ($sort = array_search($col->id, $vars['columns']))) {
$this->columns->remove($col);
continue;
}
$col->set('sort', $sort+1);
$col->update($vars, $errors);
unset($new[$sort]);
}
// Add new columns
foreach ($new as $sort=>$colid) {
$col = QueueColumn::create(array("id" => $colid, "queue" => $this));
$col->set('sort', $sort+1);
$col->update($vars, $errors);
$this->addColumn($col);
}
// Re-sort the in-memory columns array
$this->columns->sort(function($c) { return $c->sort; });
}
return 0 === count($errors);
}
function save($refetch=false) {
if (!($rv = parent::save($refetch)))
return $rv;
if ($wasnew) {
$this->path = $this->buildPath();
$this->save();
}
return $this->columns->saveAll();
}
static function create($vars=false) {
global $thisstaff;
$queue = parent::create($vars);
$queue->setFlag(SavedSearch::FLAG_QUEUE);
if ($thisstaff)
$queue->staff_id = $thisstaff->getId();
return $queue;
}
static function __create($vars) {
$q = static::create($vars);
$q->save();
return $q;
}
abstract class QueueColumnAnnotation {
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
static $icon = false;
static $desc = '';
var $config;
function __construct($config) {
$this->config = $config;
}
static function fromJson($config) {
$class = $config['c'];
if (class_exists($class))
return new $class($config);
}
static function getDescription() {
return __(static::$desc);
}
static function getIcon() {
return static::$icon;
}
static function getPositions() {
return array(
"<" => __('Start'),
"b" => __('Before'),
"a" => __('After'),
">" => __('End'),
);
}
function decorate($text, $dec) {
static $positions = array(
'<' => '<span class="pull-left">%2$s</span>%1$s',
'>' => '<span class="pull-right">%2$s</span>%1$s',
'a' => '%1$s %2$s',
'b' => '%2$s %1$s',
);
if (!isset($positions[$pos]))
return $text;
return sprintf($positions[$pos], $text, $dec);
}
// Render the annotation with the database record $row. $text is the
// text of the cell before annotations were applied.
function render($row, $cell) {
if ($decoration = $this->getDecoration($row, $cell))
return $this->decorate($cell, $decoration);
return $cell;
}
// Add the annotation to a QuerySet
abstract function annotate($query);
// Fetch some HTML to render the decoration on the page. This function
// can return boolean FALSE to indicate no decoration should be applied
abstract function getDecoration($row, $text);
function getPosition() {
return strtolower($this->config['p']) ?: 'a';
}
function getClassName() {
return @$this->config['c'] ?: get_class();
}
}
class TicketThreadCount
static $icon = 'comments-alt';
static $qname = '_thread_count';
static $desc = /* @trans */ 'Thread Count';
function annotate($query) {
return $query->annotate(array(
static::$qname => TicketThread::objects()
->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1)))
->exclude(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN))
->aggregate(array('count' => SqlAggregate::COUNT('entries__id')))
));
}
function getDecoration($row, $text) {
$threadcount = $row[static::$qname];
if ($threadcount > 1) {
return sprintf(
'<small class="faded-more"><i class="icon-comments-alt"></i> %s</small>',
$threadcount
);
}
}
}
class ThreadAttachmentCount
static $icon = 'paperclip';
static $qname = '_att_count';
static $desc = /* @trans */ 'Attachment Count';
function annotate($query) {
// TODO: Convert to Thread attachments
return $query->annotate(array(
static::$qname => TicketThread::objects()
->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1)))
->filter(array('entries__attachments__inline' => 0))
->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id')))
));
}
function getDecoration($row, $text) {
$count = $row[static::$qname];
if ($count) {
return sprintf(
'<i class="small icon-paperclip icon-flip-horizontal" data-toggle="tooltip" title="%s"></i>',
$count);
}
}
}
class OverdueFlagDecoration
static $icon = 'exclamation';
static $desc = /* @trans */ 'Overdue Icon';
function annotate($query) {
return $query->values('isoverdue');
}
function getDecoration($row, $text) {
if ($row['isoverdue'])
return '<span class="Icon overdueTicket"></span>';
}
}
class TicketSourceDecoration
static $icon = 'phone';
static $desc = /* @trans */ 'Ticket Source';
function annotate($query) {
return $query->values('source');
}
function getDecoration($row, $text) {
return sprintf('<span class="Icon %sTicket"></span>',
strtolower($row['source']));
}
}
class DataSourceField
extends ChoiceField {
function getChoices() {
$config = $this->getConfiguration();
$root = $config['root'];
$fields = array();
foreach (SavedSearch::getSearchableFields($root) as $path=>$f) {
list($label,) = $f;
$fields[$path] = $label;
}
return $fields;
}
}
class QueueColumnCondition {
var $config;
var $properties = array();
function __construct($config, $queue=null) {
if (is_array($config['prop']))
$this->properties = $config['prop'];
}
function getProperties() {
return $this->properties;
}
// Add the annotation to a QuerySet
function annotate($query) {
// Add an annotation to the query
return $query->annotate(array(
$this->getAnnotationName() => new SqlExpr(array($Q))
));
}
function getField($name=null) {
// FIXME
#$root = $this->getColumn()->getQueue()->getRoot();
$root = 'Ticket';
$searchable = SavedSearch::getSearchableFields($root);
if (!isset($name))
list($name) = $this->config['crit'];
// Lookup the field to search this condition
if (isset($searchable[$name])) {
return $searchable[$name];
}
function getFieldName() {
list($name) = $this->config['crit'];
return $name;
}
function getCriteria() {
return $this->config['crit'];
}
list($name, $method, $value) = $this->config['crit'];
// XXX: Move getOrmPath to be more of a utility
// Ensure the special join is created to support custom data joins
$name = @SavedSearch::getOrmPath($name, $query);
$name2 = null;
if (preg_match('/__answers!\d+__/', $name)) {
// Ensure that only one record is returned from the join through
// the entry and answers joins
$name2 = $this->getAnnotationName().'2';
$query->annotate(array($name2 => SqlAggregate::MAX($name)));
}
if (list(,$field) = $this->getField($name))
return $field->getSearchQ($method, $value, $name2 ?: $name);
/**
* Take the criteria from the SavedSearch fields setup and isolate the
* field name being search, the method used for searhing, and the method-
* specific data entered in the UI.
*/
static function isolateCriteria($criteria, $root='Ticket') {
$searchable = SavedSearch::getSearchableFields($root);
foreach ($criteria as $k=>$v) {
if (substr($k, -7) === '+method') {
list($name,) = explode('+', $k, 2);
if (!isset($searchable[$name]))
continue;
// Lookup the field to search this condition
list($label, $field) = $searchable[$name];
// Get the search method and value
$method = $v;
// Not all search methods require a value
$value = $criteria["{$name}+{$method}"];
}
}
}
function render($row, $text) {
$annotation = $this->getAnnotationName();
if ($V = $row[$annotation]) {
$style = array();
foreach ($this->getProperties() as $css=>$value) {
$field = QueueColumnConditionProperty::getField($css);
$field->value = $value;
$V = $field->getClean();
if (is_array($V))
$V = current($V);
$style[] = "{$css}:{$V}";
$text = sprintf('<span class="fill" style="%s">%s</span>',
}
return $text;
}
function getAnnotationName() {
// This should be predictable based on the criteria so that the
// query can deduplicate the same annotations used in different
// conditions
if (!isset($this->annotation_name)) {
$this->annotation_name = $this->getShortHash();
}
return $this->annotation_name;
function __toString() {
list($name, $method, $value) = $this->config['crit'];
if (is_array($value))
$value = implode('+', $value);
return "{$name} {$method} {$value}";
}
function getHash($binary=false) {
return sha1($this->__toString(), $binary);
}
function getShortHash() {
return substr(base64_encode($this->getHash(true)), -10);
}
static function getUid() {
return static::$uid++;
}
static function fromJson($config, $queue=null) {
$config = JsonDataParser::decode($config);
if (!is_array($config))
throw new BadMethodCallException('$config must be string or array');
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
}
}
class QueueColumnConditionProperty
extends ChoiceField {
static $properties = array(
'background-color' => 'ColorChoiceField',
'color' => 'ColorChoiceField',
'font-family' => array(
'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy',
),
'font-size' => array(
'small', 'medium', 'large', 'smaller', 'larger',
),
'font-style' => array(
'normal', 'italic', 'oblique',
),
'font-weight' => array(
'lighter', 'normal', 'bold', 'bolder',
),
'text-decoration' => array(
'none', 'underline',
),
'text-transform' => array(
'uppercase', 'lowercase', 'captalize',
),
);
function __construct($property) {
$this->property = $property;
}
static function getProperties() {
return array_keys(static::$properties);
}
static function getField($prop) {
$choices = static::$properties[$prop];
if (!isset($choices))
return null;
if (is_array($choices))
return new ChoiceField(array(
'choices' => array_combine($choices, $choices),
));
elseif (class_exists($choices))
return new $choices(array('name' => $prop));
}
function getChoices() {
if (isset($this->property))
return static::$properties[$this->property];
$keys = array_keys(static::$properties);
return array_combine($keys, $keys);
}
}
/**
* A column of a custom queue. Columns have many customizable features
* including:
* * Data Source (primary and secondary)
* * Heading
* * Link (to an object like the ticket)
* * Size and truncate settings
* * annotations (like counts and flags)
* * Conditions (which change the formatting like bold text)
*
* Columns are stored in a separate table from the queue itself, but other
* breakout items for the annotations and conditions, for instance, are stored
* as JSON text in the QueueColumn model.
*/
class QueueColumn
extends VerySimpleModel {
static $meta = array(
'table' => QUEUE_COLUMN_TABLE,
'pk' => array('id'),
'joins' => array(
'queue' => array(
'constraint' => array('queue_id' => 'SavedSearch.id'),
var $_annotations;
var $_conditions;
function getId() {
return $this->id;
}
function getQueue() {
return $this->queue;
}
function getHeading() {
return $this->heading;
}
function getWidth() {
return $this->width ?: 100;
}
function getFilter() {
if ($this->filter)
return QueueColumnFilter::getInstance($this->filter);
}
function render($row) {
// Basic data
$text = $this->renderBasicValue($row);
// Truncate
if ($text = $this->applyTruncate($text)) {
// Filter
if ($filter = $this->getFilter()) {
$text = $filter->filter($text, $row);
foreach ($this->getAnnotations() as $D) {
$text = $D->render($row, $text);
}
foreach ($this->getConditions() as $C) {
$text = $C->render($row, $text);
}
return $text;
}
function renderBasicValue($row) {
$root = ($q = $this->getQueue()) ? $q->getRoot() : 'Ticket';
$fields = SavedSearch::getSearchableFields($root);
$primary = SavedSearch::getOrmPath($this->primary);
$secondary = SavedSearch::getOrmPath($this->secondary);
// TODO: Consider data filter if configured
if (($F = $fields[$primary])
&& (list(,$field) = $F)
&& ($T = $field->from_query($row, $primary))
) {
return $field->display($field->to_php($T));
}
if (($F = $fields[$secondary])
&& (list(,$field) = $F)
&& ($T = $F->from_query($row, $secondary))
) {
return $field->display($field->to_php($T));
}
switch ($this->truncate) {
case 'ellipsis':
return sprintf('<span class="%s" style="max-width:%dpx">%s</span>',
'truncate', $this->width*1.1, $text);
return sprintf('<span class="%s" style="max-width:%dpx">%s</span>',
'truncate clip', $this->width*1.1, $text);
function addToQuery($query, $field, $path) {
if (preg_match('/__answers!\d+__/', $path)) {
// Ensure that only one record is returned from the join through
// the entry and answers joins
return $query->annotate(array(
$path => SqlAggregate::MAX($path)
));
}
return $field->addToQuery($query, $path);
}
function mangleQuery($query) {
// Basic data
$fields = SavedSearch::getSearchableFields($this->getQueue()->getRoot());
if ($primary = $fields[$this->primary]) {
list(,$field) = $primary;
$query = $this->addToQuery($query, $field,
SavedSearch::getOrmPath($this->primary, $query));
}
if ($secondary = $fields[$this->secondary]) {
list(,$field) = $secondary;
$query = $this->addToQuery($query, $field,
SavedSearch::getOrmPath($this->secondary, $query));
if ($filter = $this->getFilter())
$query = $filter->mangleQuery($query, $this);
foreach ($this->getAnnotations() as $D) {
$query = $D->annotate($query);
}
// Conditions
foreach ($this->getConditions() as $C) {
$query = $C->annotate($query);
}
return $query;
}
function getDataConfigForm($source=false) {
return new QueueColDataConfigForm($source ?: $this->ht,
array('id' => $this->id));
}
if (!isset($this->_annotations)) {
$this->_annotations = array();
if ($this->annotations
&& ($anns = JsonDataParser::decode($this->annotations))
) {
foreach ($anns as $D)
if ($T = QueueColumnAnnotation::fromJson($D))
$this->_annotations[] = $T;
}
}
}
function getConditions() {
if (!isset($this->_conditions)) {
$this->_conditions = array();
if ($this->conditions
&& ($conds = JsonDataParser::decode($this->conditions))
) {
foreach ($conds as $C)
if ($T = QueueColumnCondition::fromJson($C))
$this->_conditions[] = $T;
}
}
return $this->_conditions;
}
/**
* Create a CustomQueueColumn from vars (_POST) received from an
* update request.
*/
static function create($vars=array()) {
$inst = parent::create($vars);
// TODO: Convert annotations and conditions
return $inst;
}
function update($vars) {
$form = $this->getDataConfigForm($vars);
foreach ($form->getClean() as $k=>$v)
$this->set($k, $v);
// Do the annotations
$this->_annotations = $annotations = array();
if (isset($vars['annotations'])) {
foreach (@$vars['annotations'] as $i=>$class) {
if ($vars['deco_column'][$i] != $this->id)
continue;
if (!class_exists($class) || !is_subclass_of($class, 'QueueColumnAnnotation'))
continue;
$json = array('c' => $class, 'p' => $vars['deco_pos'][$i]);
$annotations[] = $json;
$this->_annotations[] = QueueColumnAnnotation::fromJson($json);
}
$this->_conditions = $conditions = array();
if (isset($vars['conditions'])) {
foreach (@$vars['conditions'] as $i=>$id) {
if ($vars['condition_column'][$i] != $this->id)
// Not a condition for this column
// Determine the criteria
$name = $vars['condition_field'][$i];
$fields = SavedSearch::getSearchableFields($this->getQueue()->getRoot());
if (!isset($fields[$name]))
// No such field exists for this queue root type
$parts = SavedSearch::getSearchField($fields[$name], $name);
$search_form = new SimpleForm($parts, $vars, array('id' => $id));
$search_form->getField("{$name}+search")->value = true;
$crit = $search_form->getClean();
// Check the box to enable searching on the field
$crit["{$name}+search"] = true;
// Isolate only the critical parts of the criteria
$crit = QueueColumnCondition::isolateCriteria($crit);
// Determine the properties
$props = array();
foreach ($vars['properties'] as $i=>$cid) {
if ($cid != $id)
// Not a property for this condition
continue;
// Determine the property configuration
$prop = $vars['property_name'][$i];
if (!($F = QueueColumnConditionProperty::getField($prop))) {
// Not a valid property
continue;
}
$prop_form = new SimpleForm(array($F), $vars, array('id' => $cid));
$props[$prop] = $prop_form->getField($prop)->getClean();
$json = array('crit' => $crit, 'prop' => $props);
$this->_conditions[] = QueueColumnCondition::fromJson($json);
$conditions[] = $json;
$this->annotations = JsonDataEncoder::encode($annotations);
$this->conditions = JsonDataEncoder::encode($conditions);
}
function save($refetch=false) {
if ($this->__new__ && isset($this->id))
// The ID is used to synchrize the POST data with the forms API.
// It should not be assumed to be a valid or unique database ID
// number
unset($this->id);
return parent::save($refetch);
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
abstract class QueueColumnFilter {
static $registry;
static $id = null;
static $desc = null;
static function register($filter) {
if (!isset($filter::$id))
throw new Exception('QueueColumnFilter must define $id');
if (isset(static::$registry[$filter::$id]))
throw new Exception($filter::$id
. ': QueueColumnFilter already registered under that id');
if (!is_subclass_of($filter, get_called_class()))
throw new Exception('Filter must extend QueueColumnFilter');
static::$registry[$filter::$id] = $filter;
}
static function getFilters() {
$base = static::$registry;
foreach ($base as $id=>$class) {
$base[$id] = __($class::$desc);
}
return $base;
}
static function getInstance($id) {
if (isset(static::$registry[$id]))
return new static::$registry[$id]();
}
function mangleQuery($query, $column) { return $query; }
abstract function filter($value, $row);
}
class TicketLinkFilter
extends QueueColumnFilter {
static $id = 'link:ticket';
static $desc = /* @trans */ "Ticket Link";
function filter($text, $row) {
$link = $this->getLink($row);
return sprintf('<a href="%s">%s</a>', $link, $text);
}
function mangleQuery($query, $column) {
static $fields = array(
'link:ticket' => 'ticket_id',
'link:ticketP' => 'ticket_id',
'link:user' => 'user_id',
'link:org' => 'user__org_id',
);
if (isset($fields[static::$id])) {
$query = $query->values($fields[static::$id]);
}
return $query;
}
function getLink($row) {
return Ticket::getLink($row['ticket_id']);
}
}
class UserLinkFilter
extends TicketLinkFilter {
static $id = 'link:user';
static $desc = /* @trans */ "User Link";
function getLink($row) {
return User::getLink($row['user_id']);
}
}
class OrgLinkFilter
extends TicketLinkFilter {
static $id = 'link:org';
static $desc = /* @trans */ "Organization Link";
function getLink($row) {
return Organization::getLink($row['org_id']);
}
}
QueueColumnFilter::register('TicketLinkFilter');
QueueColumnFilter::register('UserLinkFilter');
QueueColumnFilter::register('OrgLinkFilter');
class TicketLinkWithPreviewFilter
extends TicketLinkFilter {
static $id = 'link:ticketP';
static $desc = /* @trans */ "Ticket Link with Preview";
function filter($text, $row) {
$link = $this->getLink($row);
return sprintf('<a class="preview" data-preview="#tickets/%d/preview" href="%s">%s</a>',
$row['ticket_id'], $link, $text);
}
}
QueueColumnFilter::register('TicketLinkWithPreviewFilter');