diff --git a/include/class.client.php b/include/class.client.php index 1d41624bd46f360f9cef063eed0fdc32470768df..31e185c8bef7c763731c6e492214e5522414ee75 100644 --- a/include/class.client.php +++ b/include/class.client.php @@ -312,16 +312,29 @@ class EndUser extends BaseAuthenticatedUser { 'user_id' => $this->getId(), )); - // TODO: Implement UNION ALL support in the ORM + // Also add collaborator tickets to the list. This may seem ugly; + // but the general rule for SQL is that a single query can only use + // one index. Therefore, to scan two indexes (by user_id and + // thread.collaborators.user_id), we need two queries. A union will + // help out with that. $mine->union($collab->filter(array( 'thread__collaborators__user_id' => $this->getId(), Q::not(array('user_id' => $this->getId())) ))); - if ($this->getOrgId()) { + if ($orgid = $this->getOrgId()) { + // Also generate a separate query for all the tickets owned by + // either my organization or ones that I'm collaborating on + // which are not part of the organization. $myorg = clone $basic; - $myorg->filter(array('user__org_id' => $this->getOrgId())) - ->values('user__org_id'); + $myorg->values('user__org_id'); + $collab = clone $myorg; + + $myorg->filter(array('user__org_id' => $orgid)); + $myorg->union($collab->filter(array( + 'thread__collaborators__user_id' => $this->getId(), + Q::not(array('user__org_id' => $orgid)) + ))); } return array('mine' => $mine, 'myorg' => $myorg); diff --git a/include/class.nav.php b/include/class.nav.php index aafb4dc2562c92c4cc8cf09e2308cabf6343040a..e69014817a1496e555f15de1f94fae2bd6e7dbb9 100644 --- a/include/class.nav.php +++ b/include/class.nav.php @@ -344,7 +344,7 @@ class UserNav { $navs['new']=array('desc'=>__('Open a New Ticket'),'href'=>'open.php','title'=>''); if($user && $user->isValid()) { if(!$user->isGuest()) { - $navs['tickets']=array('desc'=>sprintf(__('Tickets (%d)'),$user->getNumTickets()), + $navs['tickets']=array('desc'=>sprintf(__('Tickets (%d)'),$user->getNumTickets($user->canSeeOrgTickets())), 'href'=>'tickets.php', 'title'=>__('Show all tickets')); } else { diff --git a/include/class.organization.php b/include/class.organization.php index 75024ec6bf832b6070b8883302de53e3456c3662..0c08a4f30c08281e8b473747944b3c81046a1b0d 100644 --- a/include/class.organization.php +++ b/include/class.organization.php @@ -35,6 +35,9 @@ class OrganizationModel extends VerySimpleModel { const COLLAB_PRIMARY_CONTACT = 0x0002; const ASSIGN_AGENT_MANAGER = 0x0004; + const SHARE_PRIMARY_CONTACT = 0x0008; + const SHARE_EVERYBODY = 0x0010; + const PERM_CREATE = 'org.create'; const PERM_EDIT = 'org.edit'; const PERM_DELETE = 'org.delete'; @@ -100,6 +103,14 @@ class OrganizationModel extends VerySimpleModel { return $this->check(self::ASSIGN_AGENT_MANAGER); } + function shareWithPrimaryContacts() { + return $this->check(self::SHARE_PRIMARY_CONTACT); + } + + function shareWithEverybody() { + return $this->check(self::SHARE_EVERYBODY); + } + function getUpdateDate() { return $this->updated; } @@ -206,6 +217,8 @@ implements TemplateVariable { 'collab-all-flag' => Organization::COLLAB_ALL_MEMBERS, 'collab-pc-flag' => Organization::COLLAB_PRIMARY_CONTACT, 'assign-am-flag' => Organization::ASSIGN_AGENT_MANAGER, + 'sharing-primary' => Organization::SHARE_PRIMARY_CONTACT, + 'sharing-all' => Organization::SHARE_EVERYBODY, ) as $ck=>$flag) { if ($this->check($flag)) $base[$ck] = true; @@ -393,6 +406,16 @@ implements TemplateVariable { $this->clearStatus($flag); } + foreach (array( + 'sharing-primary' => Organization::SHARE_PRIMARY_CONTACT, + 'sharing-all' => Organization::SHARE_EVERYBODY, + ) as $ck=>$flag) { + if ($vars['sharing'] == $ck) + $this->setStatus($flag); + else + $this->clearStatus($flag); + } + // Set staff and primary contacts $this->set('domain', $vars['domain']); $this->set('manager', $vars['manager'] ?: ''); @@ -427,7 +450,6 @@ implements TemplateVariable { if (!($org = Organization::lookup(array('name' => $vars['name'])))) { $org = Organization::create(array( 'name' => $vars['name'], - 'created' => new SqlFunction('NOW'), 'updated' => new SqlFunction('NOW'), )); $org->save(true); @@ -459,10 +481,17 @@ implements TemplateVariable { return $valid ? self::fromVars($form->getClean()) : null; } + static function create($vars=false) { + $org = parent::create($vars); + + $org->created = new SqlFunction('NOW'); + $org->setStatus(self::SHARE_PRIMARY_CONTACT); + return $org; + } + // Custom create called by installer/upgrader to load initial data static function __create($ht, &$error=false) { - $ht['created'] = new SqlFunction('NOW'); $org = Organization::create($ht); // Add dynamic data (if any) if ($ht['fields']) { diff --git a/include/class.orm.php b/include/class.orm.php index 44d292f664038f825478fc01bc6e2e848e78e4aa..9798d94819db755e51664e83e739b4ec4dfde6f6 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -1069,6 +1069,10 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl return $this; } + function copy() { + return clone $this; + } + function all() { return $this->getIterator()->asArray(); } @@ -2233,10 +2237,13 @@ class MySqlCompiler extends SqlCompiler { $vals = array_map(array($this, 'input'), $b); $b = '('.implode(', ', $vals).')'; } + // MySQL is almost always faster with a join. Use one if possible // MySQL doesn't support LIMIT or OFFSET in subqueries. Instead, add // the query as a JOIN and add the join constraint into the WHERE // clause. - elseif ($b instanceof QuerySet && ($b->isWindowed() || $b->countSelectFields() > 1)) { + elseif ($b instanceof QuerySet + && ($b->isWindowed() || $b->countSelectFields() > 1 || $b->chain) + ) { $f1 = $b->values[0]; $view = $b->asView(); $alias = $this->pushJoin($view, $a, $view, array('constraint'=>array())); diff --git a/include/class.ticket.php b/include/class.ticket.php index 55e3f9fbbb1da0e82d2aecf0df3258ad6e4d96e3..f849340c57ebf4d9a849d2da055e0a5124be5af8 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -358,6 +358,18 @@ implements RestrictedAccess, Threadable { if ($user->getId() == $this->getUserId()) return true; + // Organization + if ($user->canSeeOrgTickets() + && ($U = $this->getUser()) + && ($U->getOrgId() == $user->getOrgId()) + ) { + // The owner of this ticket is in the same organization as the + // user in question, and the organization is configured to allow + // the user in question to see other tickets in the + // organization. + return true; + } + // Collaborator? // 1) If the user was authorized via this ticket. if ($user->getTicketId() == $this->getId() diff --git a/include/class.user.php b/include/class.user.php index 4fcb2c3cee37b3fdf58e62f639a87f09bb540507..8d5b6836859e2aa0531bdbd659f1c680bb61c8ea 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -432,6 +432,12 @@ implements TemplateVariable { return (string) $account->getStatus(); } + function canSeeOrgTickets() { + return $this->org && ( + $this->org->shareWithEverybody() + || ($this->isPrimaryContact() && $this->org->shareWithPrimaryContacts())); + } + function register($vars, &$errors) { // user already registered? diff --git a/include/client/tickets.inc.php b/include/client/tickets.inc.php index 0b10183a927130e06f18c72d782472493a615792..fef80c6008f3bae8555da341d00dd55a237e316e 100644 --- a/include/client/tickets.inc.php +++ b/include/client/tickets.inc.php @@ -16,36 +16,27 @@ if (isset($_REQUEST['status'])) { $settings['status'] = $_REQUEST['status']; } +$org_tickets = $thisclient->canSeeOrgTickets(); if ($settings['keywords']) { // Don't show stat counts for searches $openTickets = $closedTickets = -1; } elseif ($settings['topic_id']) { - $openTickets = $thisclient->getNumTopicTicketsInState($settings['topic_id'], 'open'); - $closedTickets = $thisclient->getNumTopicTicketsInState($settings['topic_id'], 'closed'); + $openTickets = $thisclient->getNumTopicTicketsInState($settings['topic_id'], + 'open', $org_tickets); + $closedTickets = $thisclient->getNumTopicTicketsInState($settings['topic_id'], + 'closed', $org_tickets); } else { - $openTickets = $thisclient->getNumOpenTickets(); - $closedTickets = $thisclient->getNumClosedTickets(); + $openTickets = $thisclient->getNumOpenTickets($org_tickets); + $closedTickets = $thisclient->getNumClosedTickets($org_tickets); } -$tickets = TicketModel::objects(); +$tickets = Ticket::objects(); $qs = array(); $status=null; -if ($settings['status']) - $status = strtolower($settings['status']); - switch ($status) { - default: - $status = 'open'; - case 'open': - case 'closed': - $results_type = ($status == 'closed') ? __('Closed Tickets') : __('Open Tickets'); - $tickets->filter(array('status__state' => $status)); - break; -} - $sortOptions=array('id'=>'number', 'subject'=>'cdata__subject', 'status'=>'status__name', 'dept'=>'dept__name','date'=>'created'); $orderWays=array('DESC'=>'-','ASC'=>''); @@ -62,11 +53,40 @@ if($_REQUEST['order'] && $orderWays[strtoupper($_REQUEST['order'])]) $x=$sort.'_sort'; $$x=' class="'.strtolower($_REQUEST['order'] ?: 'desc').'" '; -// Add visibility constraints -$tickets->filter(Q::any(array( - 'user_id' => $thisclient->getId(), - 'thread__collaborators__user_id' => $thisclient->getId(), -))); +$basic_filter = Ticket::objects(); +if ($settings['topic_id']) { + $basic_filter = $basic_filter->filter(array('topic_id' => $settings['topic_id'])); +} + +if ($settings['status']) + $status = strtolower($settings['status']); + switch ($status) { + default: + $status = 'open'; + case 'open': + case 'closed': + $results_type = ($status == 'closed') ? __('Closed Tickets') : __('Open Tickets'); + $basic_filter->filter(array('status__state' => $status)); + break; +} + +// Add visibility constraints — use a union query to use multiple indexes, +// use UNION without "ALL" (false as second parameter to union()) to imply +// unique values +$visibility = $basic_filter->copy() + ->values_flat('ticket_id') + ->filter(array('user_id' => $thisclient->getId())) + ->union($basic_filter->copy() + ->values_flat('ticket_id') + ->filter(array('thread__collaborators__user_id' => $thisclient->getId())) + , false); + +if ($thisclient->canSeeOrgTickets()) { + $visibility = $visibility->union( + $basic_filter->copy()->values_flat('ticket_id') + ->filter(array('user__org_id' => $thisclient->getOrgId())) + , false); +} // Perform basic search if ($settings['keywords']) { @@ -75,14 +95,10 @@ if ($settings['keywords']) { $tickets->filter(array('number__startswith'=>$q)); } else { //Deep search! // Use the search engine to perform the search - $tickets = $ost->searcher->find($q, $tickets); + $tickets = $ost->searcher->find($q, $tickets)->distinct('ticket_id'); } } -if ($settings['topic_id']) { - $tickets = $tickets->filter(array('topic_id' => $settings['topic_id'])); -} - TicketForm::ensureDynamicDataView(); $total=$tickets->count(); @@ -91,6 +107,7 @@ $pageNav=new Pagenate($total, $page, PAGE_LIMIT); $qstr = '&'. Http::build_query($qs); $qs += array('sort' => $_REQUEST['sort'], 'order' => $_REQUEST['order']); $pageNav->setURL('tickets.php', $qs); +$tickets->filter(array('ticket_id__in' => $visibility)); $pageNav->paginate($tickets); $showing =$total ? $pageNav->showing() : ""; @@ -104,7 +121,6 @@ if($search) $negorder=$order=='-'?'ASC':'DESC'; //Negate the sorting -$tickets->order_by($order.$order_by); $tickets->values( 'ticket_id', 'number', 'created', 'isanswered', 'source', 'status_id', 'status__state', 'status__name', 'cdata__subject', 'dept_id', @@ -122,8 +138,9 @@ $tickets->values( <?php echo __('Help Topic'); ?>: <select name="topic_id" class="nowarn" onchange="javascript: this.form.submit(); "> <option value="">— <?php echo __('All Help Topics');?> —</option> -<?php foreach (Topic::getHelpTopics(true) as $id=>$name) { - $count = $thisclient->getNumTopicTickets($id); +<?php +foreach (Topic::getHelpTopics(true) as $id=>$name) { + $count = $thisclient->getNumTopicTickets($id, $org_tickets); if ($count == 0) continue; ?> diff --git a/include/i18n/en_US/help/tips/org.yaml b/include/i18n/en_US/help/tips/org.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0cb4219f5afe31dd8357efee506f35718b2c1ad4 --- /dev/null +++ b/include/i18n/en_US/help/tips/org.yaml @@ -0,0 +1,36 @@ +# +# This is the view / management page for an organization in the user +# directory +# +# Fields: +# title - Shown in bold at the top of the popover window +# content - The body of the help popover +# links - List of links shows below the content +# title - Link title +# href - href of link (links starting with / are translated to the +# helpdesk installation path) +# +# The key names such as 'helpdesk_name' should not be translated as they +# must match the HTML #ids put into the page template. +# +--- +org_sharing: + title: Ticket Sharing + content: > + <p> + Organization ticket sharing allows members access to tickets owned + by other members of the organization. + </p> + <p class="info-banner"> + <i class="icon-info-sign"></i> + Collaborators always have access to tickets. + </p> + +email_domain: + title: Email Domain + content: > + Users can be automatically added to this organization based on their + email domain(s). Use the box below to enter one or more domains + separated by commas. For example, enter <code>mycompany.com</code> + for users with email addresses ending in @mycompany.com + diff --git a/include/staff/templates/org-profile.tmpl.php b/include/staff/templates/org-profile.tmpl.php index 90420dab8b8909fac2d7a53d1ed0e28a31b85376..520c73ebfd2c6356fff050714aea0cb99222b163 100644 --- a/include/staff/templates/org-profile.tmpl.php +++ b/include/staff/templates/org-profile.tmpl.php @@ -20,7 +20,7 @@ if ($info['error']) { ><i class="icon-fixed-width icon-cogs faded"></i> <?php echo __('Settings'); ?></a></li> </ul> -<form method="post" class="org" action="<?php echo $action; ?>"> +<form method="post" class="org" action="<?php echo $action; ?>" data-tip-namespace="org"> <div id="orgprofile_container"> <div class="tab_content" id="profile" style="margin:5px;"> <?php @@ -98,6 +98,22 @@ if ($ticket && $ticket->getOwnerId() == $user->getId()) </select> <br/><span class="error"><?php echo $errors['contacts']; ?></span> </td> + </tr> + <tr> + <td width="180"> + <?php echo __('Ticket Sharing'); ?>: + </td> + <td> + <select name="sharing"> + <option value=""><?php echo __('Disable'); ?></option> + <option value="sharing-primary" <?php echo $info['sharing-primary'] ? 'selected="selected"' : ''; + ?>><?php echo __('Primary contacts see all tickets'); ?></option> + <option value="sharing-all" <?php echo $info['sharing-all'] ? 'selected="selected"' : ''; + ?>><?php echo __('All members see all tickets'); ?></option> + </select> + <i class="help-tip icon-question-sign" href="#org_sharing"></i> + </td> + </tr> <tr> <th colspan="2"> <?php echo __('Automated Collaboration'); ?>: @@ -123,7 +139,8 @@ if ($ticket && $ticket->getOwnerId() == $user->getId()) </tr> <tr> <th colspan="2"> - <?php echo __('Main Domain'); ?> + <?php echo __('Email Domain'); ?> + <i class="help-tip icon-question-sign" href="#email_domain"></i> </th> </tr> <tr> diff --git a/tickets.php b/tickets.php index 3467bc9ecf7d9f7e705caf97aaee615a61d3a12a..2c80ec91a0acea906a7ff5d1b9fcb050188fe78c 100644 --- a/tickets.php +++ b/tickets.php @@ -128,7 +128,7 @@ if($ticket && $ticket->checkUserAccess($thisclient)) { } else $inc='view.inc.php'; -} elseif($thisclient->getNumTickets()) { +} elseif($thisclient->getNumTickets($thisclient->canSeeOrgTickets())) { $inc='tickets.inc.php'; } else { $nav->setActiveNav('new');