diff --git a/bootstrap.php b/bootstrap.php index 8a68c184342105d0bb5d3583a8a842d389de3426..ff09b15b66be4a7433d2f46b4dd8c07431eabe46 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -98,6 +98,7 @@ class Bootstrap { define('TICKET_TABLE',$prefix.'ticket'); define('TICKET_CDATA_TABLE', $prefix.'ticket__cdata'); define('THREAD_EVENT_TABLE',$prefix.'thread_event'); + define('THREAD_REFERRAL_TABLE',$prefix.'thread_referral'); define('THREAD_COLLABORATOR_TABLE', $prefix.'thread_collaborator'); define('TICKET_STATUS_TABLE', $prefix.'ticket_status'); define('TICKET_PRIORITY_TABLE',$prefix.'ticket_priority'); diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 57df9e01333c5465944d1cb166b04b7f1ba436e9..e89ceead6b9175c76dafcfd1547d845b81214d56 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -418,6 +418,88 @@ class TicketsAjaxAPI extends AjaxController { } + function referrals($tid) { + + return $this->refer($tid); + + } + +function refer($tid, $target=null) { + global $thisstaff; + + if (!($ticket=Ticket::lookup($tid))) + Http::response(404, __('No such ticket')); + + if (!$ticket->checkStaffPerm($thisstaff, Ticket::PERM_ASSIGN) + || !($form = $ticket->getReferralForm($_POST, + array('target' => $target)))) + Http::response(403, __('Permission denied')); + + $errors = array(); + $info = array( + ':title' => sprintf(__('Ticket #%s: %s'), + $ticket->getNumber(), + __('Refer') + ), + ':action' => sprintf('#tickets/%d/refer%s', + $ticket->getId(), + ($target ? "/$target": '')), + ); + + if ($_POST) { + + switch ($_POST['do']) { + case 'refer': + if ($form->isValid() && $ticket->refer($form, $errors)) { + $_SESSION['::sysmsgs']['msg'] = sprintf( + __('%s successfully'), + sprintf( + __('%s referred to %s'), + sprintf(__('Ticket #%s'), + sprintf('<a href="tickets.php?id=%d"><b>%s</b></a>', + $ticket->getId(), + $ticket->getNumber())) + , + $form->getTarget()) + ); + Http::response(201, $ticket->getId()); + } + + $form->addErrors($errors); + $info['error'] = $errors['err'] ?: __('Unable to refer ticket'); + break; + case 'manage': + $remove = array(); + if (is_array($_POST['referrals'])) { + $remove = array(); + foreach ($_POST['referrals'] as $k => $v) + if ($v[0] == '-') + $remove[] = substr($v, 1); + if (count($remove)) { + $num = $ticket->thread->referrals + ->filter(array('id__in' => $remove)) + ->delete(); + if ($num) { + $info['msg'] = sprintf( + __('%s successfully'), + sprintf(__('Removed %d referrals'), + $num + ) + ); + } + //TODO: log removal + } + } + break; + default: + $errors['err'] = __('Unknown Action'); + } + } + + $thread = $ticket->getThread(); + include STAFFINC_DIR . 'templates/refer.tmpl.php'; +} + function assign($tid, $target=null) { global $thisstaff; @@ -545,6 +627,9 @@ class TicketsAjaxAPI extends AjaxController { 'assign' => array( 'verbed' => __('assigned'), ), + 'refer' => array( + 'verbed' => __('referred'), + ), 'claim' => array( 'verbed' => __('assigned'), ), diff --git a/include/class.forms.php b/include/class.forms.php index eaf5dbd255476418222d5f9e945a14d75964a14e..6f13a4a0ce0ef68fb6fd0ce1443e13f8c6dcd06e 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -2671,17 +2671,24 @@ class AssigneeField extends ChoiceField { } function to_php($value, $id=false) { + $type = ''; if (is_array($id)) { reset($id); $id = key($id); + $type = $id[0]; + $id = substr($id, 1); } - if ($id[0] == 's') - return Staff::lookup(substr($id, 1)); - elseif ($id[0] == 't') - return Team::lookup(substr($id, 1)); - - return $id; + switch ($type) { + case 's': + return Staff::lookup($id); + case 't': + return Team::lookup($id); + case 'd': + return Dept::lookup($id); + default: + return $id; + } } @@ -4613,6 +4620,128 @@ class ClaimForm extends AssignmentForm { } +class ReferralForm extends Form { + + static $id = 'refer'; + var $_target = null; + var $_choices = null; + var $_prompt = ''; + + function getFields() { + + if ($this->fields) + return $this->fields; + + $fields = array( + 'target' => new AssigneeField(array( + 'id'=>1, + 'label' => __('Referee'), + 'flags' => hexdec(0X450F3), + 'required' => true, + 'validator-error' => __('Selection required'), + 'configuration' => array( + 'criteria' => array( + 'available' => true, + ), + 'prompt' => $this->_prompt, + ), + ) + ), + 'comments' => new TextareaField(array( + 'id' => 2, + 'label'=> '', + 'required'=>false, + 'default'=>'', + 'configuration' => array( + 'html' => true, + 'size' => 'small', + 'placeholder' => __('Optional reason for the referral'), + ), + ) + ), + ); + + + if (isset($this->_choices)) + $fields['target']->setChoices($this->_choices); + + + $this->setFields($fields); + + return $this->fields; + } + + function getField($name) { + + if (($fields = $this->getFields()) + && isset($fields[$name])) + return $fields[$name]; + } + + function isValid($include=false) { + + if (!parent::isValid($include) || !($f=$this->getField('target'))) + return false; + + // Do additional assignment validation + $choice = $this->getTarget(); + switch (true) { + case $choice instanceof Staff: + // Make sure the agent is available + if (!$choice->isAvailable()) + $f->addError(__('Agent is unavailable for assignment')); + break; + case $choice instanceof Team: + // Make sure the team is active and has members + if (!$choice->isActive()) + $f->addError(__('Team is disabled')); + elseif (!$choice->getNumMembers()) + $f->addError(__('Team does not have members')); + break; + case $choice instanceof Dept: + break; + default: + $f->addError(__('Unknown selection')); + } + + return !$this->errors(); + } + + function render($options) { + + switch(strtolower($options['template'])) { + case 'simple': + $inc = STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php'; + break; + default: + throw new Exception(sprintf(__('%s: Unknown template style %s'), + 'FormUtils', $options['template'])); + } + + $form = $this; + include $inc; + } + + function setChoices($choices, $prompt='') { + $this->_choices = $choices; + $this->_prompt = $prompt; + $this->_fields = array(); + } + + function getTarget() { + + if (!isset($this->_target)) + $this->_target = $this->getField('target')->getClean(); + + return $this->_target; + } + + function getComments() { + return $this->getField('comments')->getClean(); + } +} + + class TransferForm extends Form { static $id = 'transfer'; diff --git a/include/class.model.php b/include/class.model.php index 5d41a05d099684b943c9ad754f2b8653b443324d..c9158a2e8d3801139b909c946012fb043dd71fad 100644 --- a/include/class.model.php +++ b/include/class.model.php @@ -22,6 +22,9 @@ class ObjectModel { const OBJECT_TYPE_FAQ = 'K'; const OBJECT_TYPE_FILE = 'F'; const OBJECT_TYPE_TASK = 'A'; + const OBJECT_TYPE_TEAM = 'E'; + const OBJECT_TYPE_DEPT = 'D'; + const OBJECT_TYPE_STAFF = 'S'; private function objects() { static $objects = false; @@ -34,6 +37,9 @@ class ObjectModel { self::OBJECT_TYPE_FAQ => 'FAQ', self::OBJECT_TYPE_FILE => 'AttachmentFile', self::OBJECT_TYPE_TASK => 'Task', + self::OBJECT_TYPE_TEAM => 'Team', + self::OBJECT_TYPE_DEPT => 'Dept', + self::OBJECT_TYPE_STAFF => 'Staff', ); } diff --git a/include/class.staff.php b/include/class.staff.php index c2c1b67670ca05575a370763fa134ea43ad14522..bbe66e4626d97a641ab1f75257594154b59ad0fa 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -540,8 +540,34 @@ implements AuthenticatedUser, EmailContact, TemplateVariable, Searchable { return $this->_teams; } - /* stats */ + function getTicketsVisibility() { + + // -- Open and assigned to me + $assigned = Q::any(array( + 'staff_id' => $this->getId(), + )); + + $assigned->add(array('thread__referrals__agent__staff_id' => $this->getId())); + + // -- Open and assigned to a team of mine + if ($teams = array_filter($this->getTeams())) { + $assigned->add(array('team_id__in' => $teams)); + $assigned->add(array('thread__referrals__team__team_id__in' => $teams)); + } + + $visibility = Q::any(new Q(array('status__state'=>'open', $assigned))); + + // -- Routed to a department of mine + if (!$this->showAssignedOnly() && ($depts=$this->getDepts())) { + $visibility->add(array('dept_id__in' => $depts)); + $visibility->add(array('thread__referrals__dept__id__in' => $depts)); + } + + return $visibility; + } + + /* stats */ function resetStats() { $this->stats = array(); } diff --git a/include/class.thread.php b/include/class.thread.php index eab798690b60242706a19e0bd9563f823e0e463f..bbb0d100b7feb758ecf39c74c14d533de313714e 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -40,6 +40,10 @@ implements Searchable { 'collaborators' => array( 'reverse' => 'Collaborator.thread', ), + + 'referrals' => array( + 'reverse' => 'ThreadReferral.thread', + ), 'entries' => array( 'reverse' => 'ThreadEntry.thread', ), @@ -104,6 +108,15 @@ implements Searchable { return $this->_entries; } + // Referrals + function getNumReferrals() { + return $this->referrals->count(); + } + + function getReferrals() { + return $this->referrals; + } + // Collaborators function getNumCollaborators() { return $this->collaborators->count(); @@ -262,6 +275,30 @@ implements Searchable { return $this->_participants; } + function refer($to) { + + $vars = array('thread_id' => $this->getId()); + switch (true) { + case $to instanceof Staff: + $vars['object_id'] = $to->getId(); + $vars['object_type'] = ObjectModel::OBJECT_TYPE_STAFF; + break; + case $to instanceof Team: + $vars['object_id'] = $to->getId(); + $vars['object_type'] = ObjectModel::OBJECT_TYPE_TEAM; + break; + case $to instanceof Dept: + $vars['object_id'] = $to->getId(); + $vars['object_type'] = ObjectModel::OBJECT_TYPE_DEPT; + break; + default: + return false; + } + + var_dump($vars); + + return ThreadReferral::create($vars); + } // Render thread function render($type=false, $options=array()) { @@ -1636,6 +1673,79 @@ implements TemplateVariable { RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions()); + +class ThreadReferral extends VerySimpleModel { + static $meta = array( + 'table' => THREAD_REFERRAL_TABLE, + 'pk' => array('id'), + 'joins' => array( + 'thread' => array( + 'constraint' => array('thread_id' => 'Thread.id'), + ), + 'agent' => array( + 'constraint' => array( + 'object_type' => "'S'", + 'object_id' => 'Staff.staff_id', + ), + ), + 'team' => array( + 'constraint' => array( + 'object_type' => "'E'", + 'object_id' => 'Team.team_id', + ), + ), + 'dept' => array( + 'constraint' => array( + 'object_type' => "'D'", + 'object_id' => 'Dept.id', + ), + ), + ) + ); + + var $icons = array( + 'E' => 'group', + 'D' => 'sitemap', + 'S' => 'user' + ); + + var $_object = null; + + function getId() { + return $this->id; + } + + function getName() { + return (string) $this->getObject(); + } + + function getObject() { + + if (!isset($this->_object)) { + $this->_object = ObjectModel::lookup( + $this->object_id, $this->object_type); + } + + return $this->_object; + } + + function getIcon() { + return $this->icons[$this->object_type]; + } + + function display() { + return sprintf('<i class="icon-%s"></i> %s', + $this->getIcon(), $this->getName()); + } + + static function create($vars) { + + $new = new self($vars); + $new->created = SqlFunction::NOW(); + return $new->save(); + } +} + class ThreadEvent extends VerySimpleModel { static $meta = array( 'table' => THREAD_EVENT_TABLE, @@ -1690,6 +1800,7 @@ class ThreadEvent extends VerySimpleModel { const REOPENED = 'reopened'; const STATUS = 'status'; const TRANSFERRED = 'transferred'; + const REFERRED = 'referred'; const VIEWED = 'viewed'; const MODE_STAFF = 1; @@ -1719,6 +1830,7 @@ class ThreadEvent extends VerySimpleModel { 'created' => 'magic', 'overdue' => 'time', 'transferred' => 'share-alt', + 'referred' => 'exchange', 'edited' => 'pencil', 'closed' => 'thumbs-up-alt', 'reopened' => 'rotate-right', @@ -1963,6 +2075,27 @@ class AssignmentEvent extends ThreadEvent { } } +class ReferralEvent extends ThreadEvent { + static $icon = 'exchange'; + static $state = 'referred'; + + function getDescription($mode=self::MODE_STAFF) { + $data = $this->getData(); + switch (true) { + case isset($data['staff']): + $desc = __('<b>{somebody}</b> referred this to <strong>{<Staff>data.staff}</strong> {timestamp}'); + break; + case isset($data['team']): + $desc = __('<b>{somebody}</b> referred this to <strong>{<Team>data.team}</strong> {timestamp}'); + break; + case isset($data['dept']): + $desc = __('<b>{somebody}</b> referred this to <strong>{<Dept>data.dept}</strong> {timestamp}'); + break; + } + return $this->template($desc); + } +} + class CloseEvent extends ThreadEvent { static $icon = 'thumbs-up-alt'; static $state = 'closed'; diff --git a/include/class.ticket.php b/include/class.ticket.php index 332f10c125736d786c7cbd9f3ec569eb0ba258c0..f91ab94bfdca21792200e98615d050ecab633dd6 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -852,6 +852,36 @@ implements RestrictedAccess, Threadable, Searchable { return $form; } + function getReferralForm($source=null, $options=array()) { + + $prompt = ''; + $choices = array(); + switch (strtolower($options['target'])) { + case 'agents': + $dept = $this->getDept(); + foreach ($dept->getAssignees() as $member) + $choices['s'.$member->getId()] = $member; + $prompt = sprintf('%s %s', __('Select an'), __('Agent')); + break; + case 'teams': + if (($teams = Team::getActiveTeams())) + foreach ($teams as $id => $name) + $choices['t'.$id] = $name; + $prompt = sprintf('%s %s', __('Select a'), __('Team')); + break; + case 'departments': + foreach (Dept::getDepartments() as $k => $v) + $choices["d$k"] = $v; + $prompt = sprintf('%s %s', __('Select a'), __('Department')); + break; + } + + $form = ReferralForm::instantiate($source, $options); + $form->setChoices($choices, $prompt); + + return $form; + } + function getClaimForm($source=null, $options=array()) { global $thisstaff; @@ -2283,6 +2313,65 @@ implements RestrictedAccess, Threadable, Searchable { return $this->unassign(); } + function refer(ReferralForm $form, &$errors, $alert=true) { + global $thisstaff; + + $evd = array(); + $choice = $form->getTarget(); + switch (true) { + case $choice instanceof Staff: + $dept = $this->getDept(); + if ($this->getStaffId() == $choice->getId()) { + $errors['target'] = sprintf(__('%s is assigned to %s'), + __('Ticket'), + __('the agent') + ); + } elseif(!$choice->isAvailable()) { + $errors['assignee'] = sprintf(__('Agent is unavailable for %s'), + __('referral')); + } elseif ($dept->assignMembersOnly() && !$dept->isMember($choice)) { + $errors['err'] = __('Permission denied'); + } else { + $evd['staff'] = array($choice->getId(), (string) $choice->getName()->getOriginal()); + } + break; + case $choice instanceof Team: + if ($this->getTeamId() == $choice->getId()) { + $errors['assignee'] = sprintf(__('%s is assigned to %s'), + __('Ticket'), + __('the team') + ); + } else { + //TODO:: + $evd = array('team' => $choice->getId()); + } + break; + case $choice instanceof Dept: + if ($this->getTeamId() == $choice->getId()) { + $errors['target'] = sprintf(__('%s is already in %s'), + __('Ticket'), + __('the department') + ); + } else { + //TODO:: + $evd = array('dept' => $choice->getId()); + } + break; + default: + $errors['target'] = __('Unknown referral'); + } + + if (!$errors && !$this->thread->refer($choice)) + $errors['err'] = __('Unable to reffer ticket'); + + if ($errors) + return false; + + $this->logEvent('referred', $evd); + + return true; + } + //Change ownership function changeOwner($user) { global $thisstaff; @@ -3073,19 +3162,7 @@ implements RestrictedAccess, Threadable, Searchable { if(!$staff || (!is_object($staff) && !($staff=Staff::lookup($staff))) || !$staff->isStaff()) return null; - // -- Open and assigned to me - $assigned = Q::any(array( - 'staff_id' => $staff->getId(), - )); - // -- Open and assigned to a team of mine - if ($teams = array_filter($staff->getTeams())) - $assigned->add(array('team_id__in' => $teams)); - - $visibility = Q::any(new Q(array('status__state'=>'open', $assigned))); - - // -- Routed to a department of mine - if (!$staff->showAssignedOnly() && ($depts = $staff->getDepts())) - $visibility->add(array('dept_id__in' => $depts)); + $visibility = $staff->getTicketsVisibility(); $blocks = Ticket::objects() ->filter(Q::any($visibility)) diff --git a/include/staff/templates/refer.tmpl.php b/include/staff/templates/refer.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..b1c850f8a779871c6d34de2dec19049c15748a2e --- /dev/null +++ b/include/staff/templates/refer.tmpl.php @@ -0,0 +1,129 @@ +<?php +global $cfg; + +if (!$thread) return; + +$form = $form ?: ReferralForm::instantiate($info); + +?> +<h3 class="drag-handle"><?php echo $info[':title'] ?: __('Refer'); ?></h3> +<b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> +<div class="clear"></div> +<hr/> +<?php +if ($info['error']) { + echo sprintf('<p id="msg_error">%s</p>', $info['error']); +} elseif ($info['warn']) { + echo sprintf('<p id="msg_warning">%s</p>', $info['warn']); +} elseif ($info['msg']) { + echo sprintf('<p id="msg_notice">%s</p>', $info['msg']); +} elseif ($info['notice']) { + echo sprintf('<p id="msg_info"><i class="icon-info-sign"></i> %s</p>', + $info['notice']); +} + +$action = $info[':action'] ?: ('#'); +$manage = (!$target); +?> +<ul class="tabs" id="referral"> + <li <?php echo !$manage ? 'class="active"' : ''; ?>><a href="#refer" + ><i class="icon-exchange"></i> <?php echo __('Refer'); ?></a></li> + <li <?php echo $manage ? 'class="active"' : ''; ?>><a href="#referrals" + ><i class="icon-list"></i> <?php + echo sprintf('%s (%d)', __('Referrals'), $thread->getNumReferrals()); ?></a></li> +</ul> +<div id="referral_container"> + <div class="tab_content <?php echo $manage ? 'hidden' : ''; ?>" id="refer" style="margin:5px;"> + <form class="mass-action" method="post" + name="assign" + id="<?php echo $form->getId(); ?>" + action="<?php echo $action; ?>"> + <input type='hidden' name='do' value='refer'> + <table width="100%"> + <?php + if ($info[':extra']) { + ?> + <tbody> + <tr><td colspan="2"><strong><?php echo $info[':extra']; + ?></strong></td> </tr> + </tbody> + <?php + } + ?> + <tbody> + <tr><td colspan=2> + <?php + $options = array('template' => 'simple', 'form_id' => 'refer'); + $form->render($options); + ?> + </td> </tr> + </tbody> + </table> + <hr> + <p class="full-width"> + <span class="buttons pull-left"> + <input type="reset" value="<?php echo __('Reset'); ?>"> + <input type="button" name="cancel" class="close" + value="<?php echo __('Cancel'); ?>"> + </span> + <span class="buttons pull-right"> + <input type="submit" value="<?php + echo __('Refer'); ?>"> + </span> + </p> + </form> + </div> + <div class="tab_content <?php echo !$manage ? 'hidden' : ''; ?>" id="referrals" style="margin:5px;"> + <form class="mass-action" method="post" + name="referrals" + id="rf" + action="<?php echo sprintf('#/tickets/%d/referrals', $ticket->getId()); ?>"> + <input type='hidden' name='do' value='manage'> + <table width="100%"> + <tbody> + <?php + if ($thread->referrals->count()) { + foreach ($thread->referrals as $r) { + ?> + <tr> + <td style="border-top: 1px solid #ddd;"> <?php echo $r->display(); ?></td> + <td style="border-top: 1px solid #ddd;"> + <div style="position:relative"> + <input type="hidden" name="referrals[]" value="<?php echo $r->getId(); ?>"/> + <div class="pull-right" style="right:2px;"> + <a href="#" title="<?php echo __('clear'); ?>" onclick="javascript: + if (!confirm(__('You sure?'))) + return false; + $(this).closest('td').find('input[name=\'referrals[]\']') + .val(function(i,v) { return '-'+ v; }); + $(this).closest('tr').fadeOut(400, function() { $(this).hide(); }); + $('input[type=submit], button[type=submit]', + $(this).closest('form')).addClass('save pending'); + return false;"><i class="icon-trash"></i></a> + </div> + </div> + </td> + </tr> + <?php } + } ?> + </tbody> + </table> + <hr> + <?php + if ($thread->getNumReferrals()) {?> + <p class="full-width"> + <span class="buttons pull-left"> + <input type="button" name="cancel" class="close" + value="<?php echo __('Cancel'); ?>"> + </span> + <span class="buttons pull-right"> + <input type="submit" value="<?php + echo __('Save Changes'); ?>"> + </span> + </p> + <?php + } ?> + </form> + </div> +</div> +<div class="clear"></div> diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index a977ab2c6cc1fa387b996011e0c9d1167c552630..81965fbcef6a6f8ca021d1cae63e8f296c654dd8 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -92,6 +92,33 @@ if($ticket->isOverdue()) data-redirect="tickets.php" href="#tickets/<?php echo $ticket->getId(); ?>/transfer"><i class="icon-share"></i></a> </span> + <span class="action-button pull-right" + data-dropdown="#action-dropdown-refer" + data-placement="bottom" + data-toggle="tooltip" + title=" <?php echo __('Refer'); ?>" + > + <i class="icon-caret-down pull-right"></i> + <a class="ticket-action" id="ticket-refer" + data-redirect="tickets.php" + href="#tickets/<?php echo $ticket->getId(); ?>/refer"><i class="icon-exchange"></i></a> + </span> + <div id="action-dropdown-refer" class="action-dropdown anchor-right"> + <ul> + <li><a class="no-pjax ticket-action" + data-redirect="tickets.php" + href="#tickets/<?php echo $ticket->getId(); ?>/refer/agents"><i + class="icon-user"></i> <?php echo __('Agent'); ?></a> + <li><a class="no-pjax ticket-action" + data-redirect="tickets.php" + href="#tickets/<?php echo $ticket->getId(); ?>/refer/teams"><i + class="icon-group"></i> <?php echo __('Team'); ?></a> + <li><a class="no-pjax ticket-action" + data-redirect="tickets.php" + href="#tickets/<?php echo $ticket->getId(); ?>/refer/departments"><i + class="icon-sitemap"></i> <?php echo __('Department'); ?></a> + </ul> + </div> <?php } ?> diff --git a/scp/ajax.php b/scp/ajax.php index 215079e18c80f65ef5d1b9776fc5f6d586df7a3d..b935414c1de4544a0c448b7b448770b17f05db2e 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -165,6 +165,8 @@ $dispatcher = patterns('', url('^mass/(?P<action>\w+)(?:/(?P<what>\w+))?', 'massProcess'), url('^(?P<tid>\d+)/transfer$', 'transfer'), url('^(?P<tid>\d+)/assign(?:/(?P<to>\w+))?$', 'assign'), + url('^(?P<tid>\d+)/refer(?:/(?P<to>\w+))?$', 'refer'), + url('^(?P<tid>\d+)/referrals$', 'referrals'), url('^(?P<tid>\d+)/claim$', 'claim'), url('^search', patterns('ajax.search.php:SearchAjaxAPI', url_get('^$', 'getAdvancedSearchDialog'),