[Rt-commit] rt branch, 4.4/custom-roles, created. rt-4.2.12-366-g670ddf8

Shawn Moore shawn at bestpractical.com
Tue Oct 27 12:05:24 EDT 2015


The branch, 4.4/custom-roles has been created
        at  670ddf8b97d4a83da0ff494c52d93b5e5fe1103f (commit)

- Log -----------------------------------------------------------------
commit 3da168ac6be16ecec5557894356c5ef414c78cf7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:46:15 2015 +0000

    Tidy EmailInput and avoid including unescaped parameters

diff --git a/share/html/Elements/EmailInput b/share/html/Elements/EmailInput
index e894a14..5d171d3 100644
--- a/share/html/Elements/EmailInput
+++ b/share/html/Elements/EmailInput
@@ -45,7 +45,25 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<input type="text" id="<% $Name %>" name="<% $Name %>" <% defined $Size ? qq{size="$Size"} : '' |n %> value="<% $Default || '' %>" <% $Autocomplete ? q{data-autocomplete="Users"} : '' |n%> <% $AutocompleteMultiple ? q{data-autocomplete-multiple} : '' |n%> />
+<input
+    type="text"
+    id="<% $Name %>"
+    name="<% $Name %>"
+    value="<% $Default || '' %>"
+
+% if (defined $Size) {
+    size="<% $Size %>"
+% }
+
+% if ($Autocomplete) {
+    data-autocomplete="Users"
+% }
+
+% if ($AutocompleteMultiple) {
+    data-autocomplete-multiple
+% }
+
+/>
 <%ARGS>
 $Name
 $Size    => 40

commit f4a3966036c9f79a722e13588eea6975fdf80550
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:47:21 2015 +0000

    Placeholder and EntryHint for EmailInput

diff --git a/share/html/Elements/EmailInput b/share/html/Elements/EmailInput
index 5d171d3..a30ee92 100644
--- a/share/html/Elements/EmailInput
+++ b/share/html/Elements/EmailInput
@@ -55,6 +55,10 @@
     size="<% $Size %>"
 % }
 
+% if ($Placeholder) {
+    placeholder="<% $Placeholder %>"
+% }
+
 % if ($Autocomplete) {
     data-autocomplete="Users"
 % }
@@ -64,10 +68,18 @@
 % }
 
 />
+% if ($EntryHint) {
+<br>
+<i><font size="-2">
+  <&|/l&><% $EntryHint %></&>
+</font></i>
+% }
 <%ARGS>
 $Name
 $Size    => 40
 $Default => ''
 $Autocomplete => 1
 $AutocompleteMultiple => 0
+$EntryHint => ''
+$Placeholder => ''
 </%ARGS>

commit 5b777ea20c948bc0a4d38524203d74e1d0472669
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:49:19 2015 +0000

    Support autocomplete-return for EmailInput
    
        It's not the best-named component, but this is a useful addition

diff --git a/share/html/Elements/EmailInput b/share/html/Elements/EmailInput
index a30ee92..c2bb3b9 100644
--- a/share/html/Elements/EmailInput
+++ b/share/html/Elements/EmailInput
@@ -67,6 +67,10 @@
     data-autocomplete-multiple
 % }
 
+% if ($AutocompleteReturn) {
+    data-autocomplete-return="<% $AutocompleteReturn %>"
+% }
+
 />
 % if ($EntryHint) {
 <br>
@@ -80,6 +84,7 @@ $Size    => 40
 $Default => ''
 $Autocomplete => 1
 $AutocompleteMultiple => 0
+$AutocompleteReturn => ''
 $EntryHint => ''
 $Placeholder => ''
 </%ARGS>

commit 116841608c9174983c63f187cf10984fdba3fd62
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:50:17 2015 +0000

    Support for (by default, off) autocomplete of Nobody and System

diff --git a/share/html/Elements/EmailInput b/share/html/Elements/EmailInput
index c2bb3b9..8d63c31 100644
--- a/share/html/Elements/EmailInput
+++ b/share/html/Elements/EmailInput
@@ -71,6 +71,14 @@
     data-autocomplete-return="<% $AutocompleteReturn %>"
 % }
 
+% if ($AutocompleteNobody) {
+    data-autocomplete-include-nobody
+% }
+
+% if ($AutocompleteSystem) {
+    data-autocomplete-include-system
+% }
+
 />
 % if ($EntryHint) {
 <br>
@@ -85,6 +93,8 @@ $Default => ''
 $Autocomplete => 1
 $AutocompleteMultiple => 0
 $AutocompleteReturn => ''
+$AutocompleteNobody => 0
+$AutocompleteSystem => 0
 $EntryHint => ''
 $Placeholder => ''
 </%ARGS>
diff --git a/share/html/Helpers/Autocomplete/Users b/share/html/Helpers/Autocomplete/Users
index a8df1c4..bd40d5e 100644
--- a/share/html/Helpers/Autocomplete/Users
+++ b/share/html/Helpers/Autocomplete/Users
@@ -56,6 +56,8 @@ $max => undef
 $privileged => undef
 $exclude => ''
 $op => undef
+$include_nobody => 0
+$include_system => 0
 </%ARGS>
 <%INIT>
 # Only allow certain return fields
@@ -86,7 +88,8 @@ $m->abort unless $CurrentUser->Privileged
 
 # the API wants a list of ids
 my @exclude = split /\s*,\s*/, $exclude;
-push @exclude, RT->SystemUser->id, RT->Nobody->id;
+push @exclude, RT->SystemUser->id unless $include_system;
+push @exclude, RT->Nobody->id unless $include_nobody;
 
 $m->callback( CallbackName => 'ModifyMaxResults', max => \$max );
 $max //= 10;
diff --git a/share/static/js/autocomplete.js b/share/static/js/autocomplete.js
index 957d6f2..badf128 100644
--- a/share/static/js/autocomplete.js
+++ b/share/static/js/autocomplete.js
@@ -33,6 +33,14 @@ window.RT.Autocomplete.bind = function(from) {
             queryargs.push("privileged=1");
         }
 
+        if (input.is('[data-autocomplete-include-nobody]')) {
+            queryargs.push("include_nobody=1");
+        }
+
+        if (input.is('[data-autocomplete-include-system]')) {
+            queryargs.push("include_system=1");
+        }
+
         if (input.is('[data-autocomplete-multiple]')) {
             if ( what != 'Tickets' ) {
                 queryargs.push("delim=,");

commit 87d20c2ccfc53e2585649e73a53cb7f424a5b216
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:51:24 2015 +0000

    Only include one copy of "check box to delete" for queue watchers

diff --git a/share/html/Admin/Elements/EditQueueWatchers b/share/html/Admin/Elements/EditQueueWatchers
index c4c1a6f..b544a93 100644
--- a/share/html/Admin/Elements/EditQueueWatchers
+++ b/share/html/Admin/Elements/EditQueueWatchers
@@ -63,7 +63,6 @@
 % }
 % }
 </ul>
-<i><&|/l&>(Check box to delete)</&></i><br /><br />
 
 <%INIT>
 my $Members = $Watchers->MembersObj;
diff --git a/share/html/Admin/Queues/People.html b/share/html/Admin/Queues/People.html
index 2d0bb91..620e3ff 100644
--- a/share/html/Admin/Queues/People.html
+++ b/share/html/Admin/Queues/People.html
@@ -61,6 +61,8 @@
 
 <h3><&|/l&>Current watchers</&></h3>
 
+<i><&|/l&>(Check box to delete)</&></i><br /><br />
+
 % for my $Name (RT::Queue->ManageableRoleGroupTypes) {
 <& /Admin/Elements/EditQueueWatcherGroup, Label => loc($Name), QueueObj => $QueueObj, Watchers => $QueueObj->$Name &>
 % }

commit 7c59fae30a85d62f31309485c5edef9c4c20a67a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:52:14 2015 +0000

    Simplify IsManageableRoleGroupType
    
        Rather than having the logic twice, just grep ManageableRoleGroupTypes

diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index aaddbc6..688c258 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -526,7 +526,7 @@ Returns whether the passed-in type is a manageable role group type.
 sub IsManageableRoleGroupType {
     my $self = shift;
     my $type = shift;
-    return( $self->HasRole($type) and not $self->Role($type)->{ACLOnly} );
+    return grep { $type eq $_ } $self->ManageableRoleGroupTypes;
 }
 
 

commit 78cbe3df4e81b124ce71707c38bfcf381c241cdc
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:55:43 2015 +0000

    Switch several hardcoded lists to use ->Roles

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index db0b5f6..21440df 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -2190,13 +2190,7 @@ sub CreateTicket {
     my %create_args = (
         Type => $ARGS{'Type'} || 'ticket',
         Queue => $ARGS{'Queue'},
-        Owner => $ARGS{'Owner'},
         SLA => $ARGS{'SLA'},
-
-        # note: name change
-        Requestor       => $ARGS{'Requestors'},
-        Cc              => $ARGS{'Cc'},
-        AdminCc         => $ARGS{'AdminCc'},
         InitialPriority => $ARGS{'InitialPriority'},
         FinalPriority   => $ARGS{'FinalPriority'},
         TimeLeft        => $ARGS{'TimeLeft'},
@@ -2209,6 +2203,10 @@ sub CreateTicket {
         MIMEObj         => $MIMEObj,
         SquelchMailTo   => $ARGS{'SquelchMailTo'},
         TransSquelchMailTo => $ARGS{'TransSquelchMailTo'},
+
+        (map { $_ => $ARGS{$_} } $Queue->Roles),
+        # note: name change
+        Requestor       => $ARGS{'Requestors'},
     );
 
     my @txn_squelch;
diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index 296949b..b47a61a 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -451,9 +451,10 @@ sub Create {
     }
 
     # Codify what it takes to add each kind of group
+    my $always_ok = sub { 1 };
     my %acls = (
-        Cc        => sub { 1 },
-        Requestor => sub { 1 },
+        (map { $_ => $always_ok } $QueueObj->Roles),
+
         AdminCc   => sub {
             my $principal = shift;
             return 1 if $self->CurrentUserHasRight('ModifyTicket');

commit 8b8897b1a2c44191f54ff8904294e9db1c2faa04
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 01:56:51 2015 +0000

    Avoid empty results and undef warnings in bulk update

diff --git a/share/html/Search/Bulk.html b/share/html/Search/Bulk.html
index 8b02c0c..50f8fcf 100644
--- a/share/html/Search/Bulk.html
+++ b/share/html/Search/Bulk.html
@@ -289,6 +289,7 @@ unless ( $ARGS{'AddMoreAttach'} ) {
         my @cfresults = ProcessRecordBulkCustomFields( RecordObj => $Ticket, ARGSRef => \%ARGS );
 
         my @tempresults = (
+            grep { defined }
             @watchresults,  @basicresults, @dateresults,
             @updateresults, @linkresults,  @cfresults
         );

commit 89d776ea37f6803e36bacf82ef48eb43148e7077
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:04:00 2015 +0000

    Tidy AddWatchers

diff --git a/share/html/Ticket/Elements/AddWatchers b/share/html/Ticket/Elements/AddWatchers
index 4093c26..c9a5c6d 100644
--- a/share/html/Ticket/Elements/AddWatchers
+++ b/share/html/Ticket/Elements/AddWatchers
@@ -58,7 +58,10 @@
 <&|/l&>Username</&>
 </td></tr>
 % while (my $u = $Users->Next ) {
-<tr><td><&/Elements/SelectWatcherType, Name => "Ticket-AddWatcher-Principal-". $u->PrincipalId &></td><td><& '/Elements/ShowUser', User => $u, style=>'verbose' &></td></tr>
+<tr>
+<td><&/Elements/SelectWatcherType, Name  => "Ticket-AddWatcher-Principal-". $u->PrincipalId &></td>
+<td><& '/Elements/ShowUser', User => $u, style=>'verbose' &></td>
+</tr>
 % }
 % }
 
@@ -69,7 +72,10 @@
 <&|/l&>Group</&>
 </td></tr>
 % while (my $g = $Groups->Next ) {
-<tr><td><&/Elements/SelectWatcherType, Name => "Ticket-AddWatcher-Principal-".$g->PrincipalId &></td><td><%$g->Name%> (<%$g->Description%>)</td></tr>
+<tr>
+<td><& /Elements/SelectWatcherType, Name  => "Ticket-AddWatcher-Principal-".$g->PrincipalId, &></td>
+<td><%$g->Name%> (<%$g->Description%>)</td>
+</tr>
 % }
 % }
 
@@ -88,21 +94,13 @@
 <%$email->format%>
 </td></tr>
 % }
+% for my $i (1 .. 3) {
 <tr><td>
-<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail1" &>
+<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail" . $i &>
 </td><td>
-<& /Elements/EmailInput, Name => 'WatcherAddressEmail1', Size => '20' &>
-</td></tr>
-<tr><td>
-<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail2" &> 
-</td><td>
-<& /Elements/EmailInput, Name => 'WatcherAddressEmail2', Size => '20' &>
-</td></tr>
-<tr><td>
-<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail3" &>
-</td><td>
-<& /Elements/EmailInput, Name => 'WatcherAddressEmail3', Size => '20' &>
+<& /Elements/EmailInput, Name => 'WatcherAddressEmail' . $i, Size => '20' &>
 </td></tr>
+% }
 </table>
 
 <%INIT>

commit accaf5b12504ca74d7ac74c3fa85954654f06d8d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:05:55 2015 +0000

    Use the record we have for inspecting roles, rather than class RT::Queue

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 725c0d7..3e2162d 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -226,7 +226,7 @@ sub Role {
 
 =head2 Roles
 
-Returns a list of role names registered for this class, sorted ascending by
+Returns a list of role names registered for this object, sorted ascending by
 SortOrder and then alphabetically by name.
 
 Optionally takes a hash specifying attributes the returned roles must possess
diff --git a/share/html/Admin/Queues/People.html b/share/html/Admin/Queues/People.html
index 620e3ff..878cc58 100644
--- a/share/html/Admin/Queues/People.html
+++ b/share/html/Admin/Queues/People.html
@@ -63,7 +63,7 @@
 
 <i><&|/l&>(Check box to delete)</&></i><br /><br />
 
-% for my $Name (RT::Queue->ManageableRoleGroupTypes) {
+% for my $Name ($QueueObj->ManageableRoleGroupTypes) {
 <& /Admin/Elements/EditQueueWatcherGroup, Label => loc($Name), QueueObj => $QueueObj, Watchers => $QueueObj->$Name &>
 % }
 
@@ -93,7 +93,8 @@
 % while (my $u = $Users->Next ) {
 <li><& /Elements/SelectWatcherType,
     Scope => 'queue',
-    Name => "Queue-AddWatcher-Principal-". $u->PrincipalId,
+    Name  => "Queue-AddWatcher-Principal-". $u->PrincipalId,
+    Queue => $QueueObj,
 &>
 <& /Elements/ShowUser, User => $u &></li>
 % }
@@ -109,9 +110,12 @@
 % } elsif ($Groups) {
 <ul>
 % while (my $g = $Groups->Next ) {
-<li><&/Elements/SelectWatcherType, Scope=>'queue', Name =>
-"Queue-AddWatcher-Principal-".$g->PrincipalId &> <%$g->Name%>
-(<%$g->Description%>)
+<li><& /Elements/SelectWatcherType,
+    Scope => 'queue',
+    Name  => "Queue-AddWatcher-Principal-".$g->PrincipalId,
+    Queue => $QueueObj,
+&>
+<%$g->Name%> (<%$g->Description%>)
 % }
 </ul>
 % }
@@ -153,7 +157,7 @@ unless ($OnlySearchForPeople or $OnlySearchForGroup) {
         next unless $key =~ /^Queue-AddWatcher-Principal-(\d*)$/;
         my $id = $1;
 
-        next unless RT::Queue->IsManageableRoleGroupType($type);
+        next unless $QueueObj->IsManageableRoleGroupType($type);
 
         my ($code, $msg) = $QueueObj->AddWatcher(
             Type => $type,
diff --git a/share/html/Elements/SelectWatcherType b/share/html/Elements/SelectWatcherType
index 105d1f4..c6df730 100644
--- a/share/html/Elements/SelectWatcherType
+++ b/share/html/Elements/SelectWatcherType
@@ -57,15 +57,17 @@
 <%INIT>
 my @types;
 if ($Scope =~ /queue/) {
-   @types = RT::Queue->ManageableRoleGroupTypes;
+    @types = $Queue->ManageableRoleGroupTypes;
 }
 else { 
-   @types = qw(Requestor Cc AdminCc);
+    @types = $Queue->Roles(Single => 0);
 }
+
 </%INIT>
 <%ARGS>
 $AllowNull => 1
 $Default=>undef
 $Scope => 'ticket'
 $Name => 'WatcherType'
+$Queue => undef
 </%ARGS>
diff --git a/share/html/Ticket/Elements/AddWatchers b/share/html/Ticket/Elements/AddWatchers
index c9a5c6d..542b080 100644
--- a/share/html/Ticket/Elements/AddWatchers
+++ b/share/html/Ticket/Elements/AddWatchers
@@ -59,7 +59,10 @@
 </td></tr>
 % while (my $u = $Users->Next ) {
 <tr>
-<td><&/Elements/SelectWatcherType, Name  => "Ticket-AddWatcher-Principal-". $u->PrincipalId &></td>
+<td><&/Elements/SelectWatcherType,
+    Name  => "Ticket-AddWatcher-Principal-". $u->PrincipalId,
+    Queue => $Ticket->QueueObj,
+&></td>
 <td><& '/Elements/ShowUser', User => $u, style=>'verbose' &></td>
 </tr>
 % }
@@ -73,7 +76,10 @@
 </td></tr>
 % while (my $g = $Groups->Next ) {
 <tr>
-<td><& /Elements/SelectWatcherType, Name  => "Ticket-AddWatcher-Principal-".$g->PrincipalId, &></td>
+<td><& /Elements/SelectWatcherType,
+    Name  => "Ticket-AddWatcher-Principal-".$g->PrincipalId,
+    Queue => $Ticket->QueueObj,
+&></td>
 <td><%$g->Name%> (<%$g->Description%>)</td>
 </tr>
 % }
@@ -88,7 +94,7 @@
 % for my $email (@extras) {
 % $counter++;
 <tr><td>
-<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail".$counter &>
+<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail".$counter, Queue => $Ticket->QueueObj &>
 </td><td>
 <input type="hidden" name="WatcherAddressEmail<%$counter%>" value="<%$email->format%>">
 <%$email->format%>
@@ -96,7 +102,7 @@
 % }
 % for my $i (1 .. 3) {
 <tr><td>
-<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail" . $i &>
+<&/Elements/SelectWatcherType, Name => "WatcherTypeEmail" . $i, Queue => $Ticket->QueueObj &>
 </td><td>
 <& /Elements/EmailInput, Name => 'WatcherAddressEmail' . $i, Size => '20' &>
 </td></tr>

commit 3d5ba26d14ad5b6f01b371512593081aedd9d2e2
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:10:14 2015 +0000

    Add a CheckRight param to ->RoleGroup

diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 688c258..75318ac 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -656,9 +656,7 @@ If the user doesn't have "ShowQueue" permission, returns an empty group
 sub Cc {
     my $self = shift;
 
-    return RT::Group->new($self->CurrentUser)
-        unless $self->CurrentUserHasRight('SeeQueue');
-    return $self->RoleGroup( 'Cc' );
+    return $self->RoleGroup( 'Cc', CheckRight => 'SeeQueue' );
 }
 
 
@@ -674,9 +672,7 @@ If the user doesn't have "ShowQueue" permission, returns an empty group
 sub AdminCc {
     my $self = shift;
 
-    return RT::Group->new($self->CurrentUser)
-        unless $self->CurrentUserHasRight('SeeQueue');
-    return $self->RoleGroup( 'AdminCc' );
+    return $self->RoleGroup( 'AdminCc', CheckRight => 'SeeQueue' );
 }
 
 
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 3e2162d..b6d8656 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -294,8 +294,14 @@ L<RT::Group> object on failure.
 sub RoleGroup {
     my $self  = shift;
     my $name  = shift;
+    my %args  = @_;
+
     my $group = RT::Group->new( $self->CurrentUser );
 
+    if ($args{CheckRight}) {
+        return $group if !$self->CurrentUserHasRight($args{CheckRight});
+    }
+
     if ($self->HasRole($name)) {
         $group->LoadRoleGroup(
             Object  => $self,

commit f467d55824284d24db9957673dafe44a82d991f1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:12:55 2015 +0000

    Fix one-off "Administrative Cc" with "AdminCc"

diff --git a/share/html/Ticket/Elements/EditPeople b/share/html/Ticket/Elements/EditPeople
index 8d5a418..3c840f7 100644
--- a/share/html/Ticket/Elements/EditPeople
+++ b/share/html/Ticket/Elements/EditPeople
@@ -81,7 +81,7 @@
 </tr>
 
 <tr>
-  <td class="label"><&|/l&>Administrative Cc</&>:</td>
+  <td class="label"><&|/l&>Admin Cc</&>:</td>
   <td class="value"><& EditWatchers, TicketObj => $Ticket, Watchers => $Ticket->AdminCc &></td>
 </tr>
 

commit 1c0c7d051c501dca20057f55dec37044e06cd061
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:15:42 2015 +0000

    Provide queue to EditBasics for new tickets that don't have a ->QueueObj

diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
index d446624..ea1aa5d 100644
--- a/share/html/Ticket/Create.html
+++ b/share/html/Ticket/Create.html
@@ -72,6 +72,7 @@
     <table width="100%" border="0">
     <& /Ticket/Elements/EditBasics,
         InTable => 1,
+        QueueObj => $QueueObj,
         fields  => [
             {   name => 'Queue',
                 comp => '/Ticket/Elements/ShowQueue',
diff --git a/share/html/Ticket/Elements/EditBasics b/share/html/Ticket/Elements/EditBasics
index 7b5708b..b478cde 100644
--- a/share/html/Ticket/Elements/EditBasics
+++ b/share/html/Ticket/Elements/EditBasics
@@ -47,11 +47,16 @@
 %# END BPS TAGGED BLOCK }}}
 <%ARGS>
 $TicketObj => undef
+$QueueObj => undef
 @fields => ()
 $InTable => 0
 %defaults => ()
 </%ARGS>
 <%INIT>
+if ($TicketObj) {
+    $QueueObj ||= $TicketObj->QueueObj;
+}
+
 unless ( @fields ) {
     my $subject = $defaults{'Subject'} || $TicketObj->Subject;
     @fields = (
@@ -71,7 +76,7 @@ unless ( @fields ) {
             comp => '/Elements/SelectQueue',
             args => {
                 Name => 'Queue',
-                Default => $defaults{'Queue'} || $TicketObj->QueueObj->Id,
+                Default => $defaults{'Queue'} || $QueueObj->Id,
                 ShowNullOption => 0,
             }
         },
@@ -79,13 +84,13 @@ unless ( @fields ) {
             comp => '/Elements/SelectOwner',
             args => {
                 Name => 'Owner',
-                QueueObj => $TicketObj->QueueObj,
+                QueueObj => $QueueObj,
                 TicketObj => $TicketObj,
                 Default => $defaults{'Owner'} || $TicketObj->OwnerObj->Id,
                 DefaultValue => 0,
             }
         },
-        $TicketObj->QueueObj->SLADisabled ? () : (
+        $QueueObj->SLADisabled ? () : (
         {   name => 'SLA',
             comp => '/Elements/SelectSLA',
             args => {

commit 4caef7dde614ab9c215cf27a49c2e5d4a22e34b1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:17:26 2015 +0000

    Provide defaults to EditBasics for "Add More Attach" etc

diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
index ea1aa5d..e99f4b9 100644
--- a/share/html/Ticket/Create.html
+++ b/share/html/Ticket/Create.html
@@ -73,6 +73,7 @@
     <& /Ticket/Elements/EditBasics,
         InTable => 1,
         QueueObj => $QueueObj,
+        defaults => \%ARGS,
         fields  => [
             {   name => 'Queue',
                 comp => '/Ticket/Elements/ShowQueue',

commit a31d3084b4daadf8f7b5a5f356147ed65d207e26
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:25:00 2015 +0000

    Factor out a CanonicalizePrincipal from AddRoleMember

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index b6d8656..b9442e3 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -311,64 +311,52 @@ sub RoleGroup {
     return $group;
 }
 
-=head2 AddRoleMember
-
-Adds the described L<RT::Principal> to the specified role group for this record.
+=head2 CanonicalizePrincipal
 
-Takes a set of key-value pairs:
+Takes some description of a principal (see below) and returns the corresponding
+L<RT::Principal>. C<Type>, as in role name, is a required parameter for
+producing error messages.
 
 =over 4
 
+=item Principal
+
+The L<RT::Principal> if you've already got it.
+
 =item PrincipalId
 
-Optional.  The ID of the L<RT::Principal> object to add.
+The ID of the L<RT::Principal> object.
 
 =item User
 
-Optional.  The Name or EmailAddress of an L<RT::User> to use as the
-principal.  If an email address is given, but a user matching it cannot
-be found, a new user will be created.
+The Name or EmailAddress of an L<RT::User>.  If an email address is given, but
+a user matching it cannot be found, a new user will be created.
 
 =item Group
 
-Optional.  The Name of an L<RT::Group> to use as the principal.
-
-=item Type
-
-Required.  One of the valid roles for this record, as returned by L</Roles>.
-
-=item ACL
-
-Optional.  A subroutine reference which will be passed the role type and
-principal being added.  If it returns false, the method will fail with a
-status of "Permission denied".
+The Name of an L<RT::Group>.
 
 =back
 
-One, and only one, of I<PrincipalId>, I<User>, or I<Group> is required.
-
-Returns a tuple of (principal object which was added, message).
-
 =cut
 
-sub AddRoleMember {
+sub CanonicalizePrincipal {
     my $self = shift;
     my %args = (@_);
 
-    return (0, $self->loc("One, and only one, of PrincipalId/User/Group is required"))
-        if 1 != grep { $_ } @args{qw/PrincipalId User Group/};
+    return (0, $self->loc("One, and only one, of Principal/PrincipalId/User/Group is required"))
+        if 1 != grep { $_ } @args{qw/Principal PrincipalId User Group/};
 
-    my $type = delete $args{Type};
-    return (0, $self->loc("No valid Type specified"))
-        unless $type and $self->HasRole($type);
-
-    if ($args{PrincipalId}) {
+    if ($args{Principal}) {
+        return $args{Principal};
+    }
+    elsif ($args{PrincipalId}) {
         # Check the PrincipalId for loops
         my $principal = RT::Principal->new( $self->CurrentUser );
         $principal->Load($args{'PrincipalId'});
         if ( $principal->id and $principal->IsUser and my $email = $principal->Object->EmailAddress ) {
             return (0, $self->loc("[_1] is an address RT receives mail at. Adding it as a '[_2]' would create a mail loop",
-                                  $email, $self->loc($type)))
+                                  $email, $self->loc($args{Type})))
                 if RT::EmailParser->IsRTAddress( $email );
         }
     } else {
@@ -376,7 +364,7 @@ sub AddRoleMember {
             my $name = delete $args{User};
             # Sanity check the address
             return (0, $self->loc("[_1] is an address RT receives mail at. Adding it as a '[_2]' would create a mail loop",
-                                  $name, $self->loc($type) ))
+                                  $name, $self->loc($args{Type}) ))
                 if RT::EmailParser->IsRTAddress( $name );
 
             # Create as the SystemUser, not the current user
@@ -409,6 +397,47 @@ sub AddRoleMember {
     my $principal = RT::Principal->new( $self->CurrentUser );
     $principal->Load( $args{PrincipalId} );
 
+    return $principal;
+}
+
+=head2 AddRoleMember
+
+Adds the described L<RT::Principal> to the specified role group for this record.
+
+Takes a set of key-value pairs:
+
+=over 4
+
+=item Principal, PrincipalId, User, or Group
+
+Required. Canonicalized through L</CanonicalizePrincipal>.
+
+=item Type
+
+Required.  One of the valid roles for this record, as returned by L</Roles>.
+
+=item ACL
+
+Optional.  A subroutine reference which will be passed the role type and
+principal being added.  If it returns false, the method will fail with a
+status of "Permission denied".
+
+=back
+
+Returns a tuple of (principal object which was added, message).
+
+=cut
+
+sub AddRoleMember {
+    my $self = shift;
+    my %args = (@_);
+
+    my $principal = $self->CanonicalizePrincipal(%args);
+
+    my $type = delete $args{Type};
+    return (0, $self->loc("That role is invalid for this object"))
+        unless $type and $self->HasRole($type);
+
     my $acl = delete $args{ACL};
     return (0, $self->loc("Permission denied"))
         if $acl and not $acl->($type => $principal);
@@ -424,9 +453,9 @@ sub AddRoleMember {
     return (0, $self->loc('[_1] cannot be a group', $self->loc($type)) )
                 if $group->SingleMemberRoleGroup and $principal->IsGroup;
 
-    my ( $ok, $msg ) = $group->_AddMember( %args, RecordTransaction => !$args{Silent} );
+    my ( $ok, $msg ) = $group->_AddMember( %args, PrincipalId => $principal->Id, RecordTransaction => !$args{Silent} );
     unless ($ok) {
-        $RT::Logger->error("Failed to add $args{PrincipalId} as a member of group ".$group->Id.": ".$msg);
+        $RT::Logger->error("Failed to add principal ".$principal->Id." as a member of group ".$group->Id.": ".$msg);
 
         return ( 0, $self->loc('Could not make [_1] a [_2]',
                     $principal->Object->Name, $self->loc($type)) );

commit 60c36e3fc5d8f001359495238ed259cb6c9ef7c6
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:30:38 2015 +0000

    Consistent error message across AddRoleMember and DeleteRoleMember

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index b9442e3..08149b6 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -504,7 +504,7 @@ sub DeleteRoleMember {
     my $self = shift;
     my %args = (@_);
 
-    return (0, $self->loc("No valid Type specified"))
+    return (0, $self->loc("That role is invalid for this object"))
         unless $args{Type} and $self->HasRole($args{Type});
 
     if ($args{User}) {

commit 5a4f79629e22ff1502c0d45da72220ae90e491c0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:33:16 2015 +0000

    Factor out a _CreateRoleGroup

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 08149b6..a3358f9 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -609,21 +609,35 @@ sub _ResolveRoles {
     return (@errors);
 }
 
+sub _CreateRoleGroup {
+    my $self = shift;
+    my $name = shift;
+    my %args = (
+        @_,
+    );
+
+    my $type_obj = RT::Group->new($self->CurrentUser);
+    my ($id, $msg) = $type_obj->CreateRoleGroup(
+        Name    => $name,
+        Object  => $self,
+        %args,
+    );
+
+    unless ($id) {
+        $RT::Logger->error("Couldn't create a role group of type '$name' for ".ref($self)." ".
+                               $self->id.": ".$msg);
+        return(undef);
+    }
+
+    return $type_obj;
+}
+
 sub _CreateRoleGroups {
     my $self = shift;
     my %args = (@_);
     for my $name ($self->Roles) {
-        my $type_obj = RT::Group->new($self->CurrentUser);
-        my ($id, $msg) = $type_obj->CreateRoleGroup(
-            Name    => $name,
-            Object  => $self,
-            %args,
-        );
-        unless ($id) {
-            $RT::Logger->error("Couldn't create a role group of type '$name' for ".ref($self)." ".
-                                   $self->id.": ".$msg);
-            return(undef);
-        }
+        my ($ok) = $self->_CreateRoleGroup($name, %args);
+        return(undef) if !$ok;
     }
     return(1);
 }

commit 56ba03bc7acda15b71201f48d75586ba90354fe3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:22:19 2015 +0000

    Lazily create ticket role groups if needed

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index a3358f9..85966bd 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -443,8 +443,12 @@ sub AddRoleMember {
         if $acl and not $acl->($type => $principal);
 
     my $group = $self->RoleGroup( $type );
-    return (0, $self->loc("Role group '[_1]' not found", $type))
-        unless $group->id;
+    if (!$group->id) {
+        $group = $self->_CreateRoleGroup($type);
+        if (!$group || !$group->id) {
+            return (0, $self->loc("Role group '[_1]' not found", $type));
+        }
+    }
 
     return (0, $self->loc('[_1] is already a [_2]',
                           $principal->Object->Name, $self->loc($type)) )
diff --git a/lib/RT/SearchBuilder/Role/Roles.pm b/lib/RT/SearchBuilder/Role/Roles.pm
index 134a507..6e911d0 100644
--- a/lib/RT/SearchBuilder/Role/Roles.pm
+++ b/lib/RT/SearchBuilder/Role/Roles.pm
@@ -118,8 +118,10 @@ sub _RoleGroupsJoin {
     my $instance = $self->_RoleGroupClass eq $args{Class} ? "id" : $args{Class};
        $instance =~ s/^RT:://;
 
-    # Watcher groups are always created for each record, so we use INNER join.
+    # Watcher groups are no longer always created for each record, so we now use left join.
+    # Previously (before 4.4) this used an inner join.
     my $groups = $self->Join(
+        TYPE            => 'left',
         ALIAS1          => 'main',
         FIELD1          => $instance,
         TABLE2          => 'Groups',
diff --git a/share/html/Ticket/Elements/EditWatchers b/share/html/Ticket/Elements/EditWatchers
index 0f613ca..8ec19ab 100644
--- a/share/html/Ticket/Elements/EditWatchers
+++ b/share/html/Ticket/Elements/EditWatchers
@@ -47,11 +47,9 @@
 %# END BPS TAGGED BLOCK }}}
 <ul>
 %# Print out a placeholder if there are none.
-% unless ( $Members->Count ) {
+% if ( !$Watchers->id || $Members->Count == 0 ) {
 <li><i><&|/l&>none</&></i></li>
-% }
-
-
+% } else {
 % while ( my $watcher = $Members->Next ) {
 % my $member = $watcher->MemberObj->Object;
 <li>
@@ -76,6 +74,7 @@
 
 </li>
 % }
+% }
 </ul>
 <%INIT>
 my $Members = $Watchers->MembersObj;

commit adb0fa210dd4170da91ff1a1bb4388bea93a4bf9
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:34:41 2015 +0000

    Use queue to resolve roles on create since the ticket isn't created yet
    
        _ResolveRoles just canonicalizes role members from a string of email
        addresses, group ids, etc to arrayrefs of principals. It's not ticket
        specific, and so this change has no direct side effects, but it's required
        for custom roles.

diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index b47a61a..44d33a1 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -370,7 +370,7 @@ sub Create {
 
     # Figure out users for roles
     my $roles = {};
-    push @non_fatal_errors, $self->_ResolveRoles( $roles, %args );
+    push @non_fatal_errors, $QueueObj->_ResolveRoles( $roles, %args );
 
     $args{'Type'} = lc $args{'Type'}
         if $args{'Type'} =~ /^(ticket|approval|reminder)$/i;

commit 6b9319e09dff5421b92c9099e89a8fe840c20783
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:38:26 2015 +0000

    Process watcher updates from ticket Modify

diff --git a/share/html/Ticket/Modify.html b/share/html/Ticket/Modify.html
index d0abd04..375710b 100644
--- a/share/html/Ticket/Modify.html
+++ b/share/html/Ticket/Modify.html
@@ -95,6 +95,7 @@ $m->callback( TicketObj => $TicketObj, CustomFields => $CustomFields, ARGSRef =>
 
 unless ($skip_update) {
     push @results, ProcessTicketBasics(TicketObj => $TicketObj, ARGSRef => \%ARGS);
+    push @results, ProcessTicketWatchers(TicketObj => $TicketObj, ARGSRef => \%ARGS);
     push @results, ProcessObjectCustomFieldUpdates(Object => $TicketObj, ARGSRef => \%ARGS);
     $m->callback( CallbackName => 'ProcessUpdates', TicketObj => $TicketObj,
                   ARGSRef => \%ARGS, Results => \@results );

commit 0fe95ea03ffccaa5bd81187b9759a714fdcdf151
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:42:38 2015 +0000

    Improve messaging around updating queue/ticket watchers

diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 75318ac..d601ab0 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -571,7 +571,7 @@ sub AddWatcher {
     my ($principal, $msg) = $self->AddRoleMember( %args );
     return ( 0, $msg) unless $principal;
 
-    return ( 1, $self->loc("Added [_1] to members of [_2] for this queue.",
+    return ( 1, $self->loc("Added [_1] as [_2] for this queue",
                            $principal->Object->Name, $self->loc($args{'Type'}) ));
 }
 
@@ -601,7 +601,7 @@ sub DeleteWatcher {
     my ($principal, $msg) = $self->DeleteRoleMember( %args );
     return ( 0, $msg) unless $principal;
 
-    return ( 1, $self->loc("Removed [_1] from members of [_2] for this queue.",
+    return ( 1, $self->loc("[_1] is no longer [_2] for this queue",
                            $principal->Object->Name, $self->loc($args{'Type'}) ));
 }
 
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 85966bd..e5b160d 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -450,7 +450,7 @@ sub AddRoleMember {
         }
     }
 
-    return (0, $self->loc('[_1] is already a [_2]',
+    return (0, $self->loc('[_1] is already [_2]',
                           $principal->Object->Name, $self->loc($type)) )
             if $group->HasMember( $principal );
 
diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index 44d33a1..4e83815 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -646,16 +646,41 @@ sub AddWatcher {
         @_
     );
 
-    $args{ACL} = sub { $self->_HasModifyWatcherRight( @_ ) };
     $args{User} ||= delete $args{Email};
-    my ($principal, $msg) = $self->AddRoleMember(
-        %args,
+    my ($principal, $msg) = $self->CanonicalizePrincipal(%args);
+    if (!$principal) {
+        return (0, $msg);
+    }
+
+    my $original_user;
+    my $group = $self->RoleGroup( $args{Type} );
+    if ($group->SingleMemberRoleGroup) {
+        my $users = $group->UserMembersObj( Recursively => 0 );
+        $original_user = $users->First;
+        if ($original_user->PrincipalId == $principal->Id) {
+            return 1;
+        }
+    }
+
+    ((my $ok), $msg) = $self->AddRoleMember(
+        Principal         => $principal,
+        ACL               => sub { $self->_HasModifyWatcherRight( @_ ) },
+        Type              => $args{Type},
         InsideTransaction => 1,
     );
-    return ( 0, $msg) unless $principal;
+    return ( 0, $msg) unless $ok;
+
+    # reload group in case it was lazily created
+    $group = $self->RoleGroup( $args{Type} );
 
-    return ( 1, $self->loc('Added [_1] as a [_2] for this ticket',
-                $principal->Object->Name, $self->loc($args{'Type'})) );
+    if ($group->SingleMemberRoleGroup) {
+        return ( 1, $self->loc( "[_1] changed from [_2] to [_3]",
+                       loc($args{Type}), $original_user->Name, $principal->Object->Name ) );
+    }
+    else {
+        return ( 1, $self->loc('Added [_1] as [_2] for this ticket',
+                    $principal->Object->Name, $self->loc($args{Type})) );
+    }
 }
 
 
@@ -684,7 +709,7 @@ sub DeleteWatcher {
     return ( 0, $msg ) unless $principal;
 
     return ( 1,
-             $self->loc( "[_1] is no longer a [_2] for this ticket.",
+             $self->loc( "[_1] is no longer [_2] for this ticket",
                          $principal->Object->Name,
                          $self->loc($args{'Type'}) ) );
 }
diff --git a/t/web/ticket_modify_all.t b/t/web/ticket_modify_all.t
index 3563048..2f6e92d 100644
--- a/t/web/ticket_modify_all.t
+++ b/t/web/ticket_modify_all.t
@@ -85,7 +85,7 @@ $m->field(WatcherTypeEmail => 'Requestor');
 $m->field(WatcherAddressEmail => 'root at localhost');
 $m->click('SubmitTicket');
 $m->text_contains(
-    "Added root as a Requestor for this ticket",
+    "Added root as Requestor for this ticket",
     'watcher is added',
 );
 $m->form_name('TicketModifyAll');
@@ -93,7 +93,7 @@ $m->field(WatcherTypeEmail => 'Requestor');
 $m->field(WatcherAddressEmail => 'root at localhost');
 $m->click('SubmitTicket');
 $m->text_contains(
-    "root is already a Requestor",
+    "root is already Requestor",
     'no duplicate watchers',
 );
 

commit 7f30e068afbd7b321d9f84e160a91d41f5c856e4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 02:48:53 2015 +0000

    Additional option for roles

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index e5b160d..ca29b49 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -129,6 +129,29 @@ Optional.  A numeric value indicating the position of this role when sorted
 ascending with other roles in a list.  Roles with the same sort order are
 ordered alphabetically by name within themselves.
 
+=item UserDefined
+
+Optional.  A true value indicates that this role was created by the user and
+as such is not managed by the core codebase or an extension.
+
+=item CreateGroupPredicate
+
+Optional.  A subroutine whose return value indicates whether the group for this
+role should be created as part of L</_CreateRoleGroups>.  When this subroutine
+is not provided, the group will be created.  The same parameters that will be
+passed to L<RT::Group/CreateRoleGroup> are passed to your predicate (including
+C<Object>)
+
+=item AppliesToObjectPredicate
+
+Optional.  A subroutine which decides whether a specific object in the class
+has the role or not.
+
+=item LabelGenerator
+
+Optional.  A subroutine which returns the name of the role as suitable for
+displaying to the end user. Will receive as an argument a specific object.
+
 =back
 
 =cut
@@ -137,9 +160,13 @@ sub RegisterRole {
     my $self  = shift;
     my $class = ref($self) || $self;
     my %role  = (
-        Name            => undef,
-        EquivClasses    => [],
-        SortOrder       => 0,
+        Name                     => undef,
+        EquivClasses             => [],
+        SortOrder                => 0,
+        UserDefined              => 0,
+        CreateGroupPredicate     => undef,
+        AppliesToObjectPredicate => undef,
+        LabelGenerator           => undef,
         @_
     );
     return unless $role{Name};
@@ -258,6 +285,8 @@ sub Roles {
                     $ok = 0, last if $attr{$k} xor $_->[1]{$k};
                 }
                 $ok }
+            grep { !$_->[1]{AppliesToObjectPredicate}
+                 or $_->[1]{AppliesToObjectPredicate}->($self) }
              map { [ $_, $self->Role($_) ] }
             keys %{ $self->_ROLES };
 }
@@ -620,13 +649,20 @@ sub _CreateRoleGroup {
         @_,
     );
 
-    my $type_obj = RT::Group->new($self->CurrentUser);
-    my ($id, $msg) = $type_obj->CreateRoleGroup(
+    my $role = $self->Role($name);
+
+    my %create = (
         Name    => $name,
         Object  => $self,
         %args,
     );
 
+    return (0) if $role->{CreateGroupPredicate}
+               && !$role->{CreateGroupPredicate}->(%create);
+
+    my $type_obj = RT::Group->new($self->CurrentUser);
+    my ($id, $msg) = $type_obj->CreateRoleGroup(%create);
+
     unless ($id) {
         $RT::Logger->error("Couldn't create a role group of type '$name' for ".ref($self)." ".
                                $self->id.": ".$msg);
@@ -682,5 +718,21 @@ sub _AddRolesOnCreate {
     return @errors;
 }
 
+=head2 LabelForRole
+
+Returns a label suitable for displaying the passed-in role to an end user.
+
+=cut
+
+sub LabelForRole {
+    my $self = shift;
+    my $name = shift;
+    my $role = $self->Role($name);
+    if ($role->{LabelGenerator}) {
+        return $role->{LabelGenerator}->($self);
+    }
+    return $role->{Name};
+}
+
 
 1;

commit 67d5005e72a2271c07d43f0aff866440402f0519
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 03:00:54 2015 +0000

    Tidy watcher searching in RT::Tickets

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index eda7cab..0b90d83 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1018,18 +1018,19 @@ sub _WatcherLimit {
     my $meta = $FIELD_METADATA{ $field };
     my $type = $meta->[1] || '';
     my $class = $meta->[2] || 'Ticket';
+    my $column = $rest{SUBKEY};
 
     # Bail if the subfield is not allowed
-    if (    $rest{SUBKEY}
-        and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}})
+    if (    $column
+        and not grep { $_ eq $column } @{$SEARCHABLE_SUBFIELDS{'User'}})
     {
-        die "Invalid watcher subfield: '$rest{SUBKEY}'";
+        die "Invalid watcher subfield: '$column'";
     }
 
     $self->RoleLimit(
         TYPE      => $type,
         CLASS     => "RT::$class",
-        FIELD     => $rest{SUBKEY},
+        FIELD     => $column,
         OPERATOR  => $op,
         VALUE     => $value,
         SUBCLAUSE => "ticketsql",
@@ -1319,14 +1320,18 @@ sub OrderByCols {
         my ( $field, $subkey ) = split /\./, $row->{FIELD}, 2;
         my $meta = $FIELD_METADATA{$field};
         if ( defined $meta->[0] && $meta->[0] eq 'WATCHERFIELD' ) {
+            my $type = $meta->[1] || '';
+            my $class = $meta->[2] || 'Ticket';
+            my $column = $subkey;
+
             # cache alias as we want to use one alias per watcher type for sorting
-            my $cache_key = join "-", map { $_ || "" } @$meta[1,2];
+            my $cache_key = join "-", $type, $class;
             my $users = $self->{_sql_u_watchers_alias_for_sort}{ $cache_key };
             unless ( $users ) {
                 $self->{_sql_u_watchers_alias_for_sort}{ $cache_key }
-                    = $users = ( $self->_WatcherJoin( Name => $meta->[1], Class => "RT::" . ($meta->[2] || 'Ticket') ) )[2];
+                    = $users = ( $self->_WatcherJoin( Name => $type, Class => "RT::" . $class ) )[2];
             }
-            push @res, { %$row, ALIAS => $users, FIELD => $subkey };
+            push @res, { %$row, ALIAS => $users, FIELD => $column };
        } elsif ( defined $meta->[0] && $meta->[0] eq 'CUSTOMFIELD' ) {
            my ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $subkey );
            my $cfkey = $cf ? $cf->id : "$object.$field";

commit 708ac1eb57b368d2e893f92e8a867f1a29c35f05
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 03:13:27 2015 +0000

    Improve comments around GetPrincipalsMap

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 21440df..834bf5c 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -3835,6 +3835,13 @@ sub ProcessColumnMapValue {
 Returns an array suitable for passing to /Admin/Elements/EditRights with the
 principal collections mapped from the categories given.
 
+The return value is an array of arrays, where the inner arrays are like:
+
+    [ 'Category name' => $CollectionObj => 'DisplayColumn' => 1 ]
+
+The last value is a boolean determining if the value of DisplayColumn
+should be loc()-ed before display.
+
 =cut
 
 sub GetPrincipalsMap {
diff --git a/share/html/Admin/Elements/EditRights b/share/html/Admin/Elements/EditRights
index bd86131..684e5f8 100644
--- a/share/html/Admin/Elements/EditRights
+++ b/share/html/Admin/Elements/EditRights
@@ -79,7 +79,7 @@ if ($anchor =~ /AddPrincipal/) {
 </%init>
 %# Principals is an array of arrays, where the inner arrays are like:
 %#      [ 'Category name' => $CollectionObj => 'DisplayColumn' => 1 ]
-%# The last value is a boolen determining if the value of DisplayColumn
+%# The last value is a boolean determining if the value of DisplayColumn
 %# should be loc()-ed before display.
 
 <script type="text/javascript">

commit a96e16e7fe960e1f327f15235d6cba55beecd9d4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 15:58:28 2015 +0000

    Add a Group->Label hook for when we display a group's name in the UI
    
        Group->Name is used for transactions on watchers, role lookups, etc

diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index b79f474..66855fd 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -1425,6 +1425,23 @@ sub BasicColumns {
     );
 }
 
+=head2 Label
+
+Returns the group name suitable for displaying to end users. Override
+this instead of L</Name>, which is used internally.
+
+=cut
+
+sub Label {
+    my $self = shift;
+
+    # don't loc user-defined group names
+    if ($self->Domain eq 'UserDefined') {
+        return $self->Name;
+    }
+
+    return $self->loc($self->Name);
+}
 
 =head1 AUTHOR
 
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 834bf5c..dd26d43 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -3872,7 +3872,7 @@ sub GetPrincipalsMap {
 
             push @map, [
                 'User Groups' => $groups,   # loc_left_pair
-                'Name'        => 0
+                'Label'       => 0
             ];
         }
         elsif (/Roles/) {
@@ -3902,7 +3902,7 @@ sub GetPrincipalsMap {
                 $roles->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
                 push @map, [
                     'Roles' => $roles,  # loc_left_pair
-                    'Name'  => 1
+                    'Label' => 0
                 ];
             }
         }
diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index d601ab0..a664100 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -571,8 +571,9 @@ sub AddWatcher {
     my ($principal, $msg) = $self->AddRoleMember( %args );
     return ( 0, $msg) unless $principal;
 
+    my $group = $self->RoleGroup( $args{Type} );
     return ( 1, $self->loc("Added [_1] as [_2] for this queue",
-                           $principal->Object->Name, $self->loc($args{'Type'}) ));
+                           $principal->Object->Name, $group->Label ));
 }
 
 
@@ -601,8 +602,9 @@ sub DeleteWatcher {
     my ($principal, $msg) = $self->DeleteRoleMember( %args );
     return ( 0, $msg) unless $principal;
 
+    my $group = $self->RoleGroup( $args{Type} );
     return ( 1, $self->loc("[_1] is no longer [_2] for this queue",
-                           $principal->Object->Name, $self->loc($args{'Type'}) ));
+                           $principal->Object->Name, $group->Label ));
 }
 
 
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index ca29b49..c60f478 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -480,10 +480,10 @@ sub AddRoleMember {
     }
 
     return (0, $self->loc('[_1] is already [_2]',
-                          $principal->Object->Name, $self->loc($type)) )
+                          $principal->Object->Name, $group->Label) )
             if $group->HasMember( $principal );
 
-    return (0, $self->loc('[_1] cannot be a group', $self->loc($type)) )
+    return (0, $self->loc('[_1] cannot be a group', $group->Label) )
                 if $group->SingleMemberRoleGroup and $principal->IsGroup;
 
     my ( $ok, $msg ) = $group->_AddMember( %args, PrincipalId => $principal->Id, RecordTransaction => !$args{Silent} );
@@ -491,7 +491,7 @@ sub AddRoleMember {
         $RT::Logger->error("Failed to add principal ".$principal->Id." as a member of group ".$group->Id.": ".$msg);
 
         return ( 0, $self->loc('Could not make [_1] a [_2]',
-                    $principal->Object->Name, $self->loc($type)) );
+                    $principal->Object->Name, $group->Label) );
     }
 
     return ($principal, $msg);
diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index 4e83815..4e02e59 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -675,11 +675,11 @@ sub AddWatcher {
 
     if ($group->SingleMemberRoleGroup) {
         return ( 1, $self->loc( "[_1] changed from [_2] to [_3]",
-                       loc($args{Type}), $original_user->Name, $principal->Object->Name ) );
+                       $group->Label, $original_user->Name, $principal->Object->Name ) );
     }
     else {
         return ( 1, $self->loc('Added [_1] as [_2] for this ticket',
-                    $principal->Object->Name, $self->loc($args{Type})) );
+                    $principal->Object->Name, $group->Label) );
     }
 }
 
@@ -708,10 +708,11 @@ sub DeleteWatcher {
     my ($principal, $msg) = $self->DeleteRoleMember( %args );
     return ( 0, $msg ) unless $principal;
 
+    my $group = $self->RoleGroup( $args{Type} );
     return ( 1,
              $self->loc( "[_1] is no longer [_2] for this ticket",
                          $principal->Object->Name,
-                         $self->loc($args{'Type'}) ) );
+                         $group->Label ) );
 }
 
 
diff --git a/share/html/Admin/Elements/SelectGroups b/share/html/Admin/Elements/SelectGroups
index 491a2fa..c035311 100644
--- a/share/html/Admin/Elements/SelectGroups
+++ b/share/html/Admin/Elements/SelectGroups
@@ -47,7 +47,7 @@
 %# END BPS TAGGED BLOCK }}}
 <select multiple="multiple" name="<%$Name%>"  size="10">
 %while (my $group = $groups->Next) {
-<option value="<%$group->id%>"><%$group->Name%>
+<option value="<%$group->id%>"><%$group->Label%>
 %}
 </select>
 
diff --git a/share/html/Admin/Groups/Members.html b/share/html/Admin/Groups/Members.html
index a4ff4be..f1e4dbb 100644
--- a/share/html/Admin/Groups/Members.html
+++ b/share/html/Admin/Groups/Members.html
@@ -52,7 +52,7 @@
 <form action="<% RT->Config->Get('WebPath') %>/Admin/Groups/Members.html" method="post">
 <input type="hidden" class="hidden" name="id" value="<%$Group->Id%>" />
 
-<&| /Widgets/TitleBox, title => loc('Editing membership for group [_1]', $Group->Name) &>
+<&| /Widgets/TitleBox, title => loc('Editing membership for group [_1]', $Group->Label) &>
 
 <table width="100%">
 <tr>
@@ -114,7 +114,7 @@ my @users = sort { lc($a->[0]) cmp lc($b->[0]) }
 my $Group = RT::Group->new($session{'CurrentUser'});
 $Group->Load($id) || Abort(loc('Could not load group'));
 
-my $title = loc("Modify the group [_1]", $Group->Name);
+my $title = loc("Modify the group [_1]", $Group->Label);
 
 my (%UsersSeen, %GroupsSeen);
 $GroupsSeen{ $Group->id } = 1; # can't be a member of ourself
diff --git a/share/html/Articles/Article/Elements/SelectSearchPrivacy b/share/html/Articles/Article/Elements/SelectSearchPrivacy
index 03e7c7f..2451568 100644
--- a/share/html/Articles/Article/Elements/SelectSearchPrivacy
+++ b/share/html/Articles/Article/Elements/SelectSearchPrivacy
@@ -48,7 +48,7 @@
 <select name="<%$Name%>">
 <option value="RT::User-<% $user->Id %>" <% $Default eq 'RT::User-'.$user->Id ? 'selected' : '' %>>My searches</option>
 % while (my $group = $groups->Next) {
-<option value="RT::Group-<% $group->Id %>" <% $Default eq 'RT::Group-'.$group->Id ? 'selected' : '' %>><% $group->Name %>'s searches</option>
+<option value="RT::Group-<% $group->Id %>" <% $Default eq 'RT::Group-'.$group->Id ? 'selected' : '' %>><% $group->Label %>'s searches</option>
 % }
 </select>
 <%INIT>
diff --git a/share/html/Elements/ShowMemberships b/share/html/Elements/ShowMemberships
index 7633d68..b0d21a1 100644
--- a/share/html/Elements/ShowMemberships
+++ b/share/html/Elements/ShowMemberships
@@ -50,9 +50,9 @@
 %    my $Group = RT::Group->new($session{'CurrentUser'});
 %    $Group->Load($GroupMember->GroupId) or next;
 %    if ($Group->Domain eq 'UserDefined') {
-<li><a href="<%RT->Config->Get('WebPath')%>/Admin/Groups/Modify.html?id=<% $Group->Id %>"><% $Group->Name %></a></li>
+<li><a href="<%RT->Config->Get('WebPath')%>/Admin/Groups/Modify.html?id=<% $Group->Id %>"><% $Group->Label %></a></li>
 %    } elsif ($Group->Domain eq 'SystemInternal') {
-<li><em><% loc($Group->Name) %></em></li>
+<li><em><% $Group->Label %></em></li>
 %    }
 % }
 </ul>
diff --git a/share/html/Helpers/Autocomplete/Groups b/share/html/Helpers/Autocomplete/Groups
index 7e69484..a5ab983 100644
--- a/share/html/Helpers/Autocomplete/Groups
+++ b/share/html/Helpers/Autocomplete/Groups
@@ -87,7 +87,7 @@ foreach (split /\s*,\s*/, $exclude) {
 my @suggestions;
 
 while ( my $group = $groups->Next ) {
-    my $suggestion = { id => $group->Id, label => $group->Name, value => $group->Name };
+    my $suggestion = { id => $group->Id, label => $group->Label, value => $group->Name };
     $m->callback( CallbackName => "ModifySuggestion", suggestion => $suggestion, group => $group );
     push @suggestions, $suggestion;
 }
diff --git a/share/html/Search/Elements/SelectGroup b/share/html/Search/Elements/SelectGroup
index 27d6a76..f5716b4 100644
--- a/share/html/Search/Elements/SelectGroup
+++ b/share/html/Search/Elements/SelectGroup
@@ -50,7 +50,7 @@
 <option value="">-</option>
 % }
 %while (my $group = $groups->Next) {
-<option value="<%$group->id%>"<%$group->id eq $Default && qq[ selected="selected"] |n %>><%$group->Name%></option>
+<option value="<%$group->id%>"<%$group->id eq $Default && qq[ selected="selected"] |n %>><%$group->Label%></option>
 %}
 </select>
 

commit 826cbbafee5698b46f59b8e1d1a28e3fa176dfaf
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 03:23:30 2015 +0000

    Switch to ->LabelForRole

diff --git a/share/html/Admin/Queues/People.html b/share/html/Admin/Queues/People.html
index 878cc58..5d10be0 100644
--- a/share/html/Admin/Queues/People.html
+++ b/share/html/Admin/Queues/People.html
@@ -64,7 +64,11 @@
 <i><&|/l&>(Check box to delete)</&></i><br /><br />
 
 % for my $Name ($QueueObj->ManageableRoleGroupTypes) {
-<& /Admin/Elements/EditQueueWatcherGroup, Label => loc($Name), QueueObj => $QueueObj, Watchers => $QueueObj->$Name &>
+    <& /Admin/Elements/EditQueueWatcherGroup,
+        Label    => loc($QueueObj->LabelForRole($Name)),
+        QueueObj => $QueueObj,
+        Watchers => $QueueObj->RoleGroup($Name, CheckRight => 'SeeQueue')
+    &>
 % }
 
 % $m->callback(CallbackName => 'CurrentWatchers', QueueObj => $QueueObj);
diff --git a/share/html/Elements/SelectWatcherType b/share/html/Elements/SelectWatcherType
index c6df730..9ebd631 100644
--- a/share/html/Elements/SelectWatcherType
+++ b/share/html/Elements/SelectWatcherType
@@ -49,8 +49,12 @@
 % if ($AllowNull) {
 <option value="">-</option>
 % }
-%for my $option (@types) {
-<option value="<%$option%>"<%defined($Default) && $option eq $Default && qq[ selected="selected"] |n %>><%loc($option)%></option>
+%for my $value (@types) {
+<option value="<%$value%>"
+% if (defined($Default) && $value eq $Default) {
+selected="selected"
+% }
+><% loc($Queue->LabelForRole($value)) %></option>
 %}
 </select>
 

commit c9059b788dd395aeb9c751b18304e431a5468205
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 04:02:20 2015 +0000

    Add custom roles for queues

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 94b1f7c..7079560 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -3319,6 +3319,11 @@ Set(%AdminSearchResultFormat,
         .q{,'<a href="__WebPath__/Admin/CustomFields/Modify.html?id=__id__">__Name__</a>/TITLE:Name'}
         .q{,__AddedTo__, __EntryHint__, __FriendlyPattern__,__Disabled__},
 
+    CustomRoles =>
+        q{'<a href="__WebPath__/Admin/CustomRoles/Modify.html?id=__id__">__id__</a>/TITLE:#'}
+        .q{,'<a href="__WebPath__/Admin/CustomRoles/Modify.html?id=__id__">__Name__</a>/TITLE:Name'}
+        .q{,__Description__,__MaxValues__,__Disabled__},
+
     Scrips =>
         q{'<a href="__WebPath__/Admin/Scrips/Modify.html?id=__id____From__">__id__</a>/TITLE:#'}
         .q{,'<a href="__WebPath__/Admin/Scrips/Modify.html?id=__id____From__">__Description__</a>/TITLE:Description'}
@@ -3346,6 +3351,7 @@ Set(%AdminSearchResultRows,
     Groups       => 50,
     Users        => 50,
     CustomFields => 50,
+    CustomRoles  => 50,
     Scrips       => 50,
     Templates    => 50,
     Classes      => 50,
diff --git a/etc/acl.Pg b/etc/acl.Pg
index a659d8e..458c52a 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -58,6 +58,10 @@ sub acl {
         ObjectTopics
         objectclasses_id_seq
         ObjectClasses
+        customroles_id_seq
+        CustomRoles
+        objectcustomroles_id_seq
+        ObjectCustomRoles
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index a4a080e..59282f7 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -488,3 +488,32 @@ Created DATE,
 LastUpdatedBy NUMBER(11,0) DEFAULT 0 NOT NULL,
 LastUpdated DATE
 );
+
+CREATE SEQUENCE CUSTOMROLES_seq;
+CREATE TABLE CustomRoles (
+        id              NUMBER(11,0)
+                CONSTRAINT CustomRoles_Key PRIMARY KEY,
+        Name            VARCHAR2(200),
+        Description     VARCHAR2(255),
+        MaxValues       NUMBER(11,0) DEFAULT 0 NOT NULL,
+        EntryHint       VARCHAR2(255),
+        Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Created         DATE,
+        LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
+        LastUpdated     DATE,
+        Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL
+);
+
+CREATE SEQUENCE OBJECTCUSTOMROLES_seq;
+CREATE TABLE ObjectCustomRoles (
+        id              NUMBER(11,0)
+                 CONSTRAINT ObjectCustomRoles_Key PRIMARY KEY,
+        CustomRole       NUMBER(11,0)  NOT NULL,
+        ObjectId              NUMBER(11,0)  NOT NULL,
+        SortOrder       NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Created         DATE,
+        LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
+        LastUpdated     DATE
+);
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/schema.Pg b/etc/schema.Pg
index 0cef6a2..9f9671f 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -720,3 +720,39 @@ LastUpdated TIMESTAMP NULL,
 PRIMARY KEY (id)
 );
 
+
+CREATE SEQUENCE customroles_id_seq;
+
+CREATE TABLE CustomRoles (
+  id INTEGER DEFAULT nextval('customroles_id_seq'),
+  Name varchar(200) NULL  ,
+  Description varchar(255) NULL  ,
+  MaxValues integer NOT NULL DEFAULT 0  ,
+  EntryHint varchar(255) NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created TIMESTAMP NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated TIMESTAMP NULL  ,
+  Disabled integer NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+
+);
+
+CREATE SEQUENCE objectcustomroles_id_seq;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER DEFAULT nextval('objectscrips_id_seq'),
+  CustomRole integer NOT NULL,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created TIMESTAMP NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated TIMESTAMP NULL  ,
+  PRIMARY KEY (id)
+
+);
+
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index e37dce4..1d486ec 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -519,3 +519,32 @@ Created TIMESTAMP NULL,
 LastUpdatedBy integer NOT NULL DEFAULT 0,
 LastUpdated TIMESTAMP NULL
 );
+
+CREATE TABLE CustomRoles (
+  id INTEGER NOT NULL  ,
+  Name varchar(200) collate NOCASE NULL  ,
+  Description varchar(255) collate NOCASE NULL  ,
+  MaxValues integer,
+  EntryHint varchar(255) collate NOCASE NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  Disabled int2 NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+) ;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER NOT NULL  ,
+  CustomRole int NOT NULL  ,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  PRIMARY KEY (id)
+);
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/schema.mysql b/etc/schema.mysql
index 4d576a1..9323efb 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -509,3 +509,33 @@ CREATE TABLE ObjectClasses (
   LastUpdated datetime default NULL,
   PRIMARY KEY  (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE CustomRoles (
+  id INTEGER NOT NULL  AUTO_INCREMENT,
+  Name varchar(200) NULL  ,
+  Description varchar(255) NULL  ,
+  MaxValues integer,
+  EntryHint varchar(255) NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  Disabled int2 NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER NOT NULL  AUTO_INCREMENT,
+  CustomRole integer NOT NULL  ,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8;
+
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/upgrade/4.3.11/acl.Pg b/etc/upgrade/4.3.11/acl.Pg
new file mode 100644
index 0000000..820f7de
--- /dev/null
+++ b/etc/upgrade/4.3.11/acl.Pg
@@ -0,0 +1,33 @@
+
+sub acl {
+    my $dbh = shift;
+
+    my @acls;
+
+    my @tables = qw (
+        customroles_id_seq
+        CustomRoles
+        objectcustomroles_id_seq
+        ObjectCustomRoles
+    );
+
+    my $db_user = RT->Config->Get('DatabaseUser');
+
+    my $sequence_right
+        = ( $dbh->{pg_server_version} >= 80200 )
+        ? "USAGE, SELECT, UPDATE"
+        : "SELECT, UPDATE";
+
+    foreach my $table (@tables) {
+        # Tables are upper-case, sequences are lowercase in @tables
+        if ( $table =~ /^[a-z]/ ) {
+            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
+        }
+        else {
+            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
+        }
+    }
+    return (@acls);
+}
+
+1;
diff --git a/etc/upgrade/4.3.11/schema.Oracle b/etc/upgrade/4.3.11/schema.Oracle
new file mode 100644
index 0000000..f138d6e
--- /dev/null
+++ b/etc/upgrade/4.3.11/schema.Oracle
@@ -0,0 +1,28 @@
+CREATE SEQUENCE CUSTOMROLES_seq;
+CREATE TABLE CustomRoles (
+        id              NUMBER(11,0)
+                CONSTRAINT CustomRoles_Key PRIMARY KEY,
+        Name            VARCHAR2(200),
+        Description     VARCHAR2(255),
+        MaxValues       NUMBER(11,0) DEFAULT 0 NOT NULL,
+        EntryHint       VARCHAR2(255),
+        Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Created         DATE,
+        LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
+        LastUpdated     DATE,
+        Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL
+);
+
+CREATE SEQUENCE OBJECTCUSTOMROLES_seq;
+CREATE TABLE ObjectCustomRoles (
+        id              NUMBER(11,0)
+                 CONSTRAINT ObjectCustomRoles_Key PRIMARY KEY,
+        CustomRole       NUMBER(11,0)  NOT NULL,
+        ObjectId              NUMBER(11,0)  NOT NULL,
+        SortOrder       NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
+        Created         DATE,
+        LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
+        LastUpdated     DATE
+);
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/upgrade/4.3.11/schema.Pg b/etc/upgrade/4.3.11/schema.Pg
new file mode 100644
index 0000000..5e02ba4
--- /dev/null
+++ b/etc/upgrade/4.3.11/schema.Pg
@@ -0,0 +1,35 @@
+CREATE SEQUENCE customroles_id_seq;
+
+CREATE TABLE CustomRoles (
+  id INTEGER DEFAULT nextval('customroles_id_seq'),
+  Name varchar(200) NULL  ,
+  Description varchar(255) NULL  ,
+  MaxValues integer NOT NULL DEFAULT 0  ,
+  EntryHint varchar(255) NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created TIMESTAMP NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated TIMESTAMP NULL  ,
+  Disabled integer NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+
+);
+
+CREATE SEQUENCE objectcustomroles_id_seq;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER DEFAULT nextval('objectscrips_id_seq'),
+  CustomRole integer NOT NULL,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created TIMESTAMP NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated TIMESTAMP NULL  ,
+  PRIMARY KEY (id)
+
+);
+
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/upgrade/4.3.11/schema.SQLite b/etc/upgrade/4.3.11/schema.SQLite
new file mode 100644
index 0000000..6a47e4d
--- /dev/null
+++ b/etc/upgrade/4.3.11/schema.SQLite
@@ -0,0 +1,28 @@
+CREATE TABLE CustomRoles (
+  id INTEGER NOT NULL  ,
+  Name varchar(200) collate NOCASE NULL  ,
+  Description varchar(255) collate NOCASE NULL  ,
+  MaxValues integer,
+  EntryHint varchar(255) collate NOCASE NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  Disabled int2 NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+) ;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER NOT NULL  ,
+  CustomRole int NOT NULL  ,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  PRIMARY KEY (id)
+);
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/etc/upgrade/4.3.11/schema.mysql b/etc/upgrade/4.3.11/schema.mysql
new file mode 100644
index 0000000..427b66c
--- /dev/null
+++ b/etc/upgrade/4.3.11/schema.mysql
@@ -0,0 +1,29 @@
+CREATE TABLE CustomRoles (
+  id INTEGER NOT NULL  AUTO_INCREMENT,
+  Name varchar(200) NULL  ,
+  Description varchar(255) NULL  ,
+  MaxValues integer,
+  EntryHint varchar(255) NULL  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  Disabled int2 NOT NULL DEFAULT 0 ,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8;
+
+CREATE TABLE ObjectCustomRoles (
+  id INTEGER NOT NULL  AUTO_INCREMENT,
+  CustomRole integer NOT NULL  ,
+  ObjectId integer NOT NULL,
+  SortOrder integer NOT NULL DEFAULT 0  ,
+
+  Creator integer NOT NULL DEFAULT 0  ,
+  Created DATETIME NULL  ,
+  LastUpdatedBy integer NOT NULL DEFAULT 0  ,
+  LastUpdated DATETIME NULL  ,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8;
+
+CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
diff --git a/lib/RT.pm b/lib/RT.pm
index f0dbe37..01f2379 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -199,6 +199,7 @@ sub Init {
     InitPlugins();
     _BuildTableAttributes();
     RT::I18N->Init;
+    RT::CustomRoles->RegisterRoles;
     RT->Config->PostLoadCheck;
     RT::Lifecycle->FillCache;
 }
@@ -459,6 +460,8 @@ sub InitClasses {
     require RT::CustomFieldValues;
     require RT::ObjectCustomFields;
     require RT::ObjectCustomFieldValues;
+    require RT::CustomRoles;
+    require RT::ObjectCustomRoles;
     require RT::Attributes;
     require RT::Dashboard;
     require RT::Approval;
diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
new file mode 100644
index 0000000..c08957f
--- /dev/null
+++ b/lib/RT/CustomRole.pm
@@ -0,0 +1,676 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::CustomRole;
+use base 'RT::Record';
+
+use RT::CustomRoles;
+use RT::ObjectCustomRole;
+
+=head1 NAME
+
+RT::CustomRole - user-defined role groups
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 Table
+
+Returns table name for records of this class
+
+=cut
+
+sub Table {'CustomRoles'}
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database:
+
+  varchar(200) 'Name'.
+  varchar(255) 'Description'.
+  int(11) 'MaxValues'.
+  varchar(255) 'EntryHint'.
+  smallint(6) 'Disabled'.
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Name        => '',
+        Description => '',
+        MaxValues   => 0,
+        EntryHint   => '',
+        Disabled    => 0,
+        @_,
+    );
+
+    unless ( $self->CurrentUser->HasRight(Object => $RT::System, Right => 'AdminCustomRoles') ) {
+        return (0, $self->loc('Permission Denied'));
+    }
+
+    {
+        my ($val, $msg) = $self->_ValidateName( $args{'Name'} );
+        return ($val, $msg) unless $val;
+    }
+
+    $args{'Disabled'} ||= 0;
+    $args{'MaxValues'} = int $args{'MaxValues'};
+
+    $RT::Handle->BeginTransaction;
+
+    my ($ok, $msg) = $self->SUPER::Create(
+        Name        => $args{'Name'},
+        Description => $args{'Description'},
+        MaxValues   => $args{'MaxValues'},
+        EntryHint   => $args{'EntryHint'},
+        Disabled    => $args{'Disabled'},
+    );
+    unless ($ok) {
+        $RT::Handle->Rollback;
+        $RT::Logger->error("Couldn't create CustomRole: $msg");
+        return(undef);
+    }
+
+    # registration needs to happen before creating the system role group,
+    # otherwise its validation that you're creating a group from
+    # a valid role will fail
+    $self->_RegisterAsRole;
+
+    RT->System->CustomRoleCacheNeedsUpdate(1);
+
+    # create a system role group for assigning rights on a global level
+    # to members of this role
+    my $system_group = RT::Group->new( RT->SystemUser );
+    ($ok, $msg) = $system_group->CreateRoleGroup(
+        Name                => $self->GroupType,
+        Object              => RT->System,
+        Description         => 'SystemRolegroup for internal use',  # loc
+        InsideTransaction   => 1,
+    );
+    unless ($ok) {
+        $RT::Handle->Rollback;
+        $RT::Logger->error("Couldn't create system custom role group: $msg");
+        return(undef);
+    }
+
+    $RT::Handle->Commit;
+
+    return ($ok, $msg);
+}
+
+sub _RegisterAsRole {
+    my $self = shift;
+    my $id = $self->Id;
+
+    RT::Ticket->RegisterRole(
+        Name                 => $self->GroupType,
+        EquivClasses         => ['RT::Queue'],
+        Single               => $self->SingleValue,
+        UserDefined          => 1,
+
+        # multi-value roles can have queue-level members,
+        # single-value roles cannot (just like Owner)
+        ACLOnlyInEquiv       => $self->SingleValue,
+
+        # only create role groups for tickets in queues which
+        # have this custom role applied
+        CreateGroupPredicate => sub {
+            my %args = @_;
+            my $object = $args{Object};
+
+            my $role = RT::CustomRole->new(RT->SystemUser);
+            $role->Load($id);
+
+            if ($object->isa('RT::Queue')) {
+                # there's no way to apply the custom
+                # role to a queue before that queue is created
+                return 0;
+            }
+            elsif ($object->isa('RT::Ticket')) {
+                # see if the role has been applied to the ticket's queue
+                return $role->IsAdded($object->Queue);
+            }
+
+            return 0;
+        },
+
+        # custom roles can apply to only a subset of queues
+        AppliesToObjectPredicate => sub {
+            my $object = shift;
+
+            # reload the role to avoid capturing $self across requests
+            my $role = RT::CustomRole->new(RT->SystemUser);
+            $role->Load($id);
+
+            return 0 if $role->Disabled;
+
+            # all roles are also available on RT::System for granting rights
+            if ($object->isa('RT::System')) {
+                return 1;
+            }
+
+            # for callers not specific to any queue, e.g. ColumnMap
+            if (!ref($object)) {
+                return 1;
+            }
+
+            # custom roles apply to queues, so canonicalize a ticket
+            # into its queue
+            if ($object->isa('RT::Ticket')) {
+                $object = $object->QueueObj;
+            }
+
+            if ($object->isa('RT::Queue')) {
+                return $role->IsAdded($object->Id);
+            }
+
+            return 0;
+        },
+
+        LabelGenerator => sub {
+            my $object = shift;
+
+            # reload the role to avoid capturing $self across requests
+            my $role = RT::CustomRole->new(RT->SystemUser);
+            $role->Load($id);
+
+            return $role->Name;
+        },
+    );
+}
+
+sub _UnregisterAsRole {
+    my $self = shift;
+
+    RT::Ticket->UnregisterRole($self->GroupType);
+}
+
+=head2 Load ID/NAME
+
+Load a custom role.  If the value handed in is an integer, load by ID. Otherwise, load by name.
+
+=cut
+
+sub Load {
+    my $self = shift;
+    my $id = shift || '';
+
+    if ( $id =~ /^\d+$/ ) {
+        return $self->SUPER::Load( $id );
+    } else {
+        return $self->LoadByCols( Name => $id );
+    }
+}
+
+=head2 ValidateName NAME
+
+Takes a custom role name. Returns true if it's an ok name for
+a new custom role. Returns undef if there's already a role by that name.
+
+=cut
+
+sub ValidateName {
+    my $self = shift;
+    my $name = shift;
+
+    my ($ok, $msg) = $self->_ValidateName($name);
+
+    return $ok ? 1 : 0;
+}
+
+sub _ValidateName {
+    my $self = shift;
+    my $name = shift;
+
+    return (undef, "Role name is required") unless length $name;
+
+    # Validate via the superclass first
+    unless ( my $ok = $self->SUPER::ValidateName($name) ) {
+        return ($ok, $self->loc("'[_1]' is not a valid name.", $name));
+    }
+
+    # These roles are builtin, so avoid any potential confusion
+    if ($name =~ m{^( cc
+                    | admin[ ]?cc
+                    | requestors?
+                    | owner
+                    ) $}xi) {
+        return (undef, $self->loc("Role already exists") );
+    }
+
+    my $temp = RT::CustomRole->new(RT->SystemUser);
+    $temp->LoadByCols(Name => $name);
+
+    if ( $temp->Name && $temp->id != $self->id)  {
+        return (undef, $self->loc("Role already exists") );
+    }
+
+    return (1);
+}
+
+=head2 Delete
+
+Delete this object. You should Disable instead.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+
+    unless ( $self->CurrentUserHasRight('AdminCustomRoles') ) {
+        return ( 0, $self->loc('Permission Denied') );
+    }
+
+    RT::ObjectCustomRole->new( $self->CurrentUser )->DeleteAll( CustomRole => $self );
+
+    $self->_UnregisterAsRole;
+    RT->System->CustomRoleCacheNeedsUpdate(1);
+
+    return ( $self->SUPER::Delete(@_) );
+}
+
+=head2 IsAdded
+
+Takes an object id and returns a boolean indicating whether the custom role applies to that object
+
+=cut
+
+sub IsAdded {
+    my $self = shift;
+    my $record = RT::ObjectCustomRole->new( $self->CurrentUser );
+    $record->LoadByCols( CustomRole => $self->id, ObjectId => shift );
+    return undef unless $record->id;
+    return $record;
+}
+
+=head2 IsAddedToAny
+
+Returns a boolean of whether this custom role has been applied to any objects
+
+=cut
+
+sub IsAddedToAny {
+    my $self = shift;
+    my $record = RT::ObjectCustomRole->new( $self->CurrentUser );
+    $record->LoadByCols( CustomRole => $self->id );
+    return $record->id ? 1 : 0;
+}
+
+=head2 AddedTo
+
+Returns a collection of objects this custom role is applied to
+
+=cut
+
+sub AddedTo {
+    my $self = shift;
+    return RT::ObjectCustomRole->new( $self->CurrentUser )
+        ->AddedTo( CustomRole => $self );
+}
+
+=head2 NotAddedTo
+
+Returns a collection of objects this custom role is not applied to
+
+=cut
+
+sub NotAddedTo {
+    my $self = shift;
+    return RT::ObjectCustomRole->new( $self->CurrentUser )
+        ->NotAddedTo( CustomRole => $self );
+}
+
+=head2 AddToObject
+
+Adds (applies) this custom role to the provided queue (ObjectId).
+
+Accepts a param hash of:
+
+=over
+
+=item C<ObjectId>
+
+Queue name or id.
+
+=item C<SortOrder>
+
+Number indicating the relative order of the custom role
+
+=back
+
+Returns (val, message). If val is false, the message contains an error
+message.
+
+=cut
+
+sub AddToObject {
+    my $self = shift;
+    my %args = @_%2? (ObjectId => @_) : (@_);
+
+    my $queue = RT::Queue->new( $self->CurrentUser );
+    $queue->Load( $args{'ObjectId'} );
+    return (0, $self->loc('Invalid queue'))
+        unless $queue->id;
+
+    $args{'ObjectId'} = $queue->id;
+
+    return ( 0, $self->loc('Permission Denied') )
+        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+
+    my $rec = RT::ObjectCustomRole->new( $self->CurrentUser );
+    return $rec->Add( %args, CustomRole => $self );
+}
+
+=head2 RemoveFromObject
+
+Removes this custom role from the provided queue (ObjectId).
+
+Accepts a param hash of:
+
+=over
+
+=item C<ObjectId>
+
+Queue name or id.
+
+=back
+
+Returns (val, message). If val is false, the message contains an error
+message.
+
+=cut
+
+sub RemoveFromObject {
+    my $self = shift;
+    my %args = @_%2? (ObjectId => @_) : (@_);
+
+    my $queue = RT::Queue->new( $self->CurrentUser );
+    $queue->Load( $args{'ObjectId'} );
+    return (0, $self->loc('Invalid queue id'))
+        unless $queue->id;
+
+    return ( 0, $self->loc('Permission Denied') )
+        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+
+    my $rec = RT::ObjectCustomRole->new( $self->CurrentUser );
+    $rec->LoadByCols( CustomRole => $self->id, ObjectId => $args{'ObjectId'} );
+    return (0, $self->loc('Custom role is not added') ) unless $rec->id;
+    return $rec->Delete;
+}
+
+=head2 SingleValue
+
+Returns true if this custom role accepts only a single member.
+Returns false if it accepts multiple members.
+
+=cut
+
+sub SingleValue {
+    my $self = shift;
+    if (($self->MaxValues||0) == 1) {
+        return 1;
+    }
+    else {
+        return undef;
+    }
+}
+
+=head2 UnlimitedValues
+
+Returns true if this custom role accepts multiple members.
+Returns false if it accepts only a single member.
+
+=cut
+
+sub UnlimitedValues {
+    my $self = shift;
+    if (($self->MaxValues||0) == 0) {
+        return 1;
+    }
+    else {
+        return undef;
+    }
+}
+
+=head2 GroupType
+
+The C<Name> that groups for this custom role will have.
+
+=cut
+
+sub GroupType {
+    my $self = shift;
+    return 'RT::CustomRole-' . $self->id;
+}
+
+=head2 id
+
+Returns the current value of id.
+(In the database, id is stored as int(11).)
+
+=cut
+
+=head2 Name
+
+Returns the current value of Name.
+(In the database, Name is stored as varchar(200).)
+
+=head2 SetName VALUE
+
+Set Name to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Name will be stored as a varchar(200).)
+
+=cut
+
+=head2 Description
+
+Returns the current value of Description.
+(In the database, Description is stored as varchar(255).)
+
+=head2 SetDescription VALUE
+
+Set Description to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Description will be stored as a varchar(255).)
+
+=cut
+
+=head2 MaxValues
+
+Returns the current value of MaxValues.
+(In the database, MaxValues is stored as int(11).)
+
+=head2 SetMaxValues VALUE
+
+Set MaxValues to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, MaxValues will be stored as a int(11).)
+
+=cut
+
+sub SetMaxValues {
+    my $self = shift;
+    my $value = shift;
+
+    my ($ok, $msg) = $self->_Set( Field => 'MaxValues', Value => $value );
+
+    # update single/multi value declaration
+    $self->_RegisterAsRole;
+    RT->System->CustomRoleCacheNeedsUpdate(1);
+
+    return ($ok, $msg);
+}
+
+=head2 EntryHint
+
+Returns the current value of EntryHint.
+(In the database, EntryHint is stored as varchar(255).)
+
+=head2 SetEntryHint VALUE
+
+Set EntryHint to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, EntryHint will be stored as a varchar(255).)
+
+=cut
+
+=head2 Creator
+
+Returns the current value of Creator.
+(In the database, Creator is stored as int(11).)
+
+=cut
+
+=head2 Created
+
+Returns the current value of Created.
+(In the database, Created is stored as datetime.)
+
+=cut
+
+=head2 LastUpdatedBy
+
+Returns the current value of LastUpdatedBy.
+(In the database, LastUpdatedBy is stored as int(11).)
+
+=cut
+
+=head2 LastUpdated
+
+Returns the current value of LastUpdated.
+(In the database, LastUpdated is stored as datetime.)
+
+=cut
+
+=head2 Disabled
+
+Returns the current value of Disabled.
+(In the database, Disabled is stored as smallint(6).)
+
+=head2 SetDisabled VALUE
+
+Set Disabled to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Disabled will be stored as a smallint(6).)
+
+=cut
+
+sub SetDisabled {
+    my $self = shift;
+    my $value = shift;
+
+    $RT::Handle->BeginTransaction();
+
+    my ($ok, $msg) = $self->_Set( Field => 'Disabled', Value => $value );
+    unless ($ok) {
+        $RT::Handle->Rollback();
+        $RT::Logger->warning("Couldn't ".(($value == 0) ? "enable" : "disable")." custom role ".$self->Name.": $msg");
+        return ($ok, $msg);
+    }
+
+    my $groups = RT::Groups->new($self->CurrentUser);
+    if ($value) {
+        # if we're disabling, only need to update enabled groups
+        $groups->LimitToEnabled;
+    }
+    else {
+        $groups->LimitToDeleted;
+    }
+
+    # disable all groups for this role, so they no longer grant privileges
+    $groups->Limit(FIELD => 'Domain',   OPERATOR => 'LIKE', VALUE => "%-Role", CASESENSITIVE => 0 );
+    $groups->Limit(FIELD => 'Name',     OPERATOR => '=',    VALUE => $self->GroupType, CASESENSITIVE => 0);
+
+    while (my $group = $groups->Next) {
+        $group->SetDisabled($value);
+    }
+
+    $RT::Handle->Commit();
+
+    if ( $value == 0 ) {
+        return (1, $self->loc("Custom role enabled"));
+    } else {
+        return (1, $self->loc("Custom role disabled"));
+    }
+}
+
+sub _CoreAccessible {
+    {
+        id =>
+        {read => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        Name =>
+        {read => 1, write => 1, sql_type => 12, length => 200,  is_blob => 0,  is_numeric => 0,  type => 'varchar(200)', default => ''},
+        Description =>
+        {read => 1, write => 1, sql_type => 12, length => 255,  is_blob => 0,  is_numeric => 0,  type => 'varchar(255)', default => ''},
+        MaxValues =>
+        {read => 1, write => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        EntryHint =>
+        {read => 1, write => 1, sql_type => 12, length => 255,  is_blob => 0,  is_numeric => 0,  type => 'varchar(255)', default => ''},
+        Creator =>
+        {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        Created =>
+        {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+        LastUpdatedBy =>
+        {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        LastUpdated =>
+        {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+        Disabled =>
+        {read => 1, write => 1, sql_type => 5, length => 6,  is_blob => 0,  is_numeric => 1,  type => 'smallint(6)', default => '0'},
+ }
+};
+
+RT::Base->_ImportOverlays();
+
+1;
+
diff --git a/lib/RT/CustomRoles.pm b/lib/RT/CustomRoles.pm
new file mode 100644
index 0000000..eba2eba
--- /dev/null
+++ b/lib/RT/CustomRoles.pm
@@ -0,0 +1,177 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::CustomRoles;
+use base 'RT::SearchBuilder';
+
+use RT::CustomRole;
+
+=head1 NAME
+
+RT::CustomRoles - collection of RT::CustomRole records
+
+=head1 DESCRIPTION
+
+Collection of L<RT::CustomRole> records. Inherits methods from L<RT::SearchBuilder>.
+
+=head1 METHODS
+
+=cut
+
+=head2 Table
+
+Returns name of the table where records are stored.
+
+=cut
+
+sub Table { 'CustomRoles'}
+
+sub _Init {
+    my $self = shift;
+
+    $self->{'with_disabled_column'} = 1;
+
+    return ( $self->SUPER::_Init(@_) );
+}
+
+sub _ObjectCustomRoleAlias {
+    my $self = shift;
+    return RT::ObjectCustomRoles->new( $self->CurrentUser )
+        ->JoinTargetToThis( $self => @_ );
+}
+
+=head2 RegisterRoles
+
+This declares all (enabled) custom roles to the L<RT::Record::Role::Roles>
+subsystem, suitable for system startup.
+
+=cut
+
+sub RegisterRoles {
+    my $class = shift;
+
+    my $roles = $class->new(RT->SystemUser);
+    $roles->UnLimit;
+
+    while (my $role = $roles->Next) {
+        $role->_RegisterAsRole;
+    }
+}
+
+=head2 LimitToObjectId
+
+Takes an ObjectId and limits the collection to custom roles applied to said object.
+
+When called multiple times the ObjectId limits are joined with OR.
+
+=cut
+
+sub LimitToObjectId {
+    my $self = shift;
+    my $id = shift;
+    $self->Limit(
+        ALIAS           => $self->_ObjectCustomRoleAlias,
+        FIELD           => 'ObjectId',
+        OPERATOR        => '=',
+        VALUE           => $id,
+        ENTRYAGGREGATOR => 'OR'
+    );
+}
+
+=head2 LimitToSingleValue
+
+Limits the list of custom roles to only those that take a single value.
+
+=cut
+
+sub LimitToSingleValue {
+    my $self = shift;
+    $self->Limit(
+        FIELD    => 'MaxValues',
+        OPERATOR => '=',
+        VALUE    => 1,
+    );
+}
+
+=head2 LimitToMultipleValue
+
+Limits the list of custom roles to only those that take multiple values.
+
+=cut
+
+sub LimitToMultipleValue {
+    my $self = shift;
+    $self->Limit(
+        FIELD    => 'MaxValues',
+        OPERATOR => '=',
+        VALUE    => 0,
+    );
+}
+
+=head2 ApplySortOrder
+
+Sort custom roles according to the order provided by the object custom roles.
+
+=cut
+
+sub ApplySortOrder {
+    my $self = shift;
+    my $order = shift || 'ASC';
+    $self->OrderByCols( {
+        ALIAS => $self->_ObjectCustomRoleAlias,
+        FIELD => 'SortOrder',
+        ORDER => $order,
+    } );
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index 66855fd..b9b698c 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -84,6 +84,7 @@ use RT::Users;
 use RT::GroupMembers;
 use RT::Principals;
 use RT::ACL;
+use RT::CustomRole;
 
 __PACKAGE__->AddRight( Admin => AdminGroup           => 'Modify group metadata or delete group'); # loc
 __PACKAGE__->AddRight( Admin => AdminGroupMembership => 'Modify group membership roster'); # loc
@@ -1440,6 +1441,17 @@ sub Label {
         return $self->Name;
     }
 
+    if ($self->Domain =~ /-Role$/) {
+        my ($id) = $self->Name =~ /^RT::CustomRole-(\d+)$/;
+        if ($id) {
+            my $role = RT::CustomRole->new($self->CurrentUser);
+            $role->Load($id);
+
+            # don't loc user-defined role names
+            return $role->Name;
+        }
+    }
+
     return $self->loc($self->Name);
 }
 
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index dd26d43..9104f89 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -64,6 +64,7 @@ use warnings;
 package RT::Interface::Web;
 
 use RT::SavedSearches;
+use RT::CustomRoles;
 use URI qw();
 use RT::Interface::Web::Menu;
 use RT::Interface::Web::Session;
@@ -290,6 +291,8 @@ sub HandleRequest {
 
     ValidateWebConfig();
 
+    MaybeRebuildCustomRolesCache();
+
     DecodeARGS($ARGS);
     local $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
     PreprocessTimeUpdates($ARGS);
@@ -1264,6 +1267,15 @@ sub MaybeEnableSQLStatementLog {
 
 }
 
+my $role_cache_time = time;
+sub MaybeRebuildCustomRolesCache {
+    my $needs_update = RT->System->CustomRoleCacheNeedsUpdate;
+    if ($needs_update > $role_cache_time) {
+        RT::CustomRoles->RegisterRoles;
+        $role_cache_time = $needs_update;
+    }
+}
+
 sub LogRecordedSQLStatements {
     my %args = @_;
 
@@ -2214,6 +2226,9 @@ sub CreateTicket {
         push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} )
             if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] };
     }
+    foreach my $role (grep { /^RT::CustomRole-\d+$/ } @{ $ARGS{'SkipNotification'} || [] }) {
+        push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$role} );
+    }
     push @{$create_args{TransSquelchMailTo}}, @txn_squelch;
 
     if ( $ARGS{'AttachTickets'} ) {
@@ -2426,6 +2441,11 @@ sub _ProcessUpdateMessageRecipients {
             push @txn_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
         }
     }
+    for my $role (grep { /^RT::CustomRole-\d+$/ } @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
+        push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{$role} );
+        push @txn_squelch, $args{TicketObj}->RoleGroup($role)->MemberEmailAddresses;
+        push @txn_squelch, $args{TicketObj}->QueueObj->RoleGroup($role)->MemberEmailAddresses;
+    }
     if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
         push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} );
         push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
@@ -3404,7 +3424,7 @@ sub ProcessTicketWatchers {
         }
 
         # Delete watchers in the simple style demanded by the bulk manipulator
-        elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) {
+        elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc|RT::CustomRole-\d+)$/ ) {
             my ( $code, $msg ) = $Ticket->DeleteWatcher(
                 Email => $ARGSRef->{$key},
                 Type  => $1
@@ -3412,8 +3432,8 @@ sub ProcessTicketWatchers {
             push @results, $msg;
         }
 
-        # Add new wathchers by email address
-        elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/
+        # Add new watchers by email address
+        elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor|RT::CustomRole-\d+)$/
             and $key =~ /^WatcherTypeEmail(\d*)$/ )
         {
 
@@ -3426,7 +3446,7 @@ sub ProcessTicketWatchers {
         }
 
         #Add requestors in the simple style demanded by the bulk manipulator
-        elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) {
+        elsif ( $key =~ /^Add(Requestor|Cc|AdminCc|RT::CustomRole-\d+)$/ ) {
             my ( $code, $msg ) = $Ticket->AddWatcher(
                 Type  => $1,
                 Email => $ARGSRef->{$key}
@@ -3439,7 +3459,7 @@ sub ProcessTicketWatchers {
             my $principal_id = $1;
             my $form         = $ARGSRef->{$key};
             foreach my $value ( ref($form) ? @{$form} : ($form) ) {
-                next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i;
+                next unless $value =~ /^(?:AdminCc|Cc|Requestor|RT::CustomRole-\d+)$/i;
 
                 my ( $code, $msg ) = $Ticket->AddWatcher(
                     Type        => $value,
@@ -3448,6 +3468,17 @@ sub ProcessTicketWatchers {
                 push @results, $msg;
             }
         }
+        # Single-user custom roles
+        elsif ( $key =~ /^RT::CustomRole-(\d*)$/ ) {
+            # clearing the field sets value to nobody
+            my $user = $ARGSRef->{$key} || RT->Nobody;
+
+            my ( $code, $msg ) = $Ticket->AddWatcher(
+                Type => $key,
+                User => $user,
+            );
+            push @results, $msg;
+        }
 
     }
     return (@results);
diff --git a/lib/RT/ObjectCustomRole.pm b/lib/RT/ObjectCustomRole.pm
new file mode 100644
index 0000000..89374fc
--- /dev/null
+++ b/lib/RT/ObjectCustomRole.pm
@@ -0,0 +1,285 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::ObjectCustomRole;
+use base 'RT::Record::AddAndSort';
+
+use RT::CustomRole;
+use RT::ObjectCustomRoles;
+
+=head1 NAME
+
+RT::ObjectCustomRole - record representing addition of a custom role to a queue
+
+=head1 DESCRIPTION
+
+This record is created if you want to add a custom role to a queue.
+
+Inherits methods from L<RT::Record::AddAndSort>.
+
+For most operations it's better to use methods in L<RT::CustomRole>.
+
+=head1 METHODS
+
+=head2 Table
+
+Returns table name for records of this class.
+
+=cut
+
+sub Table {'ObjectCustomRoles'}
+
+=head2 ObjectCollectionClass
+
+Returns class name of collection of records custom roles can be added to.
+Now it's only L<RT::Queue>, so 'RT::Queues' is returned.
+
+=cut
+
+sub ObjectCollectionClass {'RT::Queues'}
+
+=head2 CustomRoleObj
+
+Returns the L<RT::CustomRole> object with the id returned by L</CustomRole>
+
+=cut
+
+sub CustomRoleObj {
+    my $self = shift;
+    my $id = shift || $self->CustomRole;
+    my $obj = RT::CustomRole->new( $self->CurrentUser );
+    $obj->Load( $id );
+    return $obj;
+}
+
+=head2 QueueObj
+
+Returns the L<RT::Queue> object which this ObjectCustomRole is added to
+
+=cut
+
+sub QueueObj {
+    my $self = shift;
+    my $queue = RT::Queue->new($self->CurrentUser);
+    $queue->Load($self->ObjectId);
+    return $queue;
+}
+
+=head2 Add
+
+
+=cut
+
+sub Add {
+    my $self = shift;
+
+    $RT::Handle->BeginTransaction;
+
+    my ($ok, $msg) = $self->SUPER::Add(@_);
+    unless ($ok) {
+        $RT::Handle->Rollback;
+        $RT::Logger->error("Couldn't add ObjectCustomRole: $msg");
+        return(undef);
+    }
+
+    my $queue = $self->QueueObj;
+    my $role = $self->CustomRoleObj;
+
+    # see if we already have this role group (which can happen if you
+    # add a role to a queue, remove it, then add it back in)
+    my $existing = RT::Group->new($self->CurrentUser);
+    $existing->LoadRoleGroup(
+        Name   => $role->GroupType,
+        Object => $queue,
+    );
+
+    if (!$existing->Id) {
+        my $group = RT::Group->new($self->CurrentUser);
+        my ($ok, $msg) = $group->CreateRoleGroup(
+            Name   => $role->GroupType,
+            Object => $queue,
+        );
+
+        unless ($ok) {
+            $RT::Handle->Rollback;
+            $RT::Logger->error("Couldn't create a role group: $msg");
+            return(undef);
+        }
+    }
+
+    $RT::Handle->Commit;
+
+    return ($ok, $msg);
+}
+
+
+=head2 id
+
+Returns the current value of id.
+(In the database, id is stored as int(11).)
+
+
+=cut
+
+
+=head2 CustomRole
+
+Returns the current value of CustomRole.
+(In the database, CustomRole is stored as int(11).)
+
+=head2 SetCustomRole VALUE
+
+
+Set CustomRole to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, CustomRole will be stored as a int(11).)
+
+=cut
+
+=head2 ObjectId
+
+Returns the current value of ObjectId.
+(In the database, ObjectId is stored as int(11).)
+
+
+
+=head2 SetObjectId VALUE
+
+
+Set ObjectId to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, ObjectId will be stored as a int(11).)
+
+
+=cut
+
+
+=head2 SortOrder
+
+Returns the current value of SortOrder.
+(In the database, SortOrder is stored as int(11).)
+
+
+
+=head2 SetSortOrder VALUE
+
+
+Set SortOrder to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, SortOrder will be stored as a int(11).)
+
+
+=cut
+
+
+=head2 Creator
+
+Returns the current value of Creator.
+(In the database, Creator is stored as int(11).)
+
+
+=cut
+
+
+=head2 Created
+
+Returns the current value of Created.
+(In the database, Created is stored as datetime.)
+
+
+=cut
+
+
+=head2 LastUpdatedBy
+
+Returns the current value of LastUpdatedBy.
+(In the database, LastUpdatedBy is stored as int(11).)
+
+
+=cut
+
+
+=head2 LastUpdated
+
+Returns the current value of LastUpdated.
+(In the database, LastUpdated is stored as datetime.)
+
+
+=cut
+
+
+
+sub _CoreAccessible {
+    {
+
+        id =>
+                {read => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        CustomRole =>
+                {read => 1, write => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        ObjectId =>
+                {read => 1, write => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        SortOrder =>
+                {read => 1, write => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        Creator =>
+                {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        Created =>
+                {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+        LastUpdatedBy =>
+                {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        LastUpdated =>
+                {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+
+ }
+};
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/ObjectCustomRoles.pm b/lib/RT/ObjectCustomRoles.pm
new file mode 100644
index 0000000..0ee7ae4
--- /dev/null
+++ b/lib/RT/ObjectCustomRoles.pm
@@ -0,0 +1,93 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::ObjectCustomRoles;
+use base 'RT::SearchBuilder::AddAndSort';
+
+use RT::CustomRoles;
+use RT::ObjectCustomRole;
+
+=head1 NAME
+
+RT::ObjectCustomRole - collection of RT::ObjectCustomRole records
+
+=head1 DESCRIPTION
+
+Collection of L<RT::ObjectCustomRole> records. Inherits methods from L<RT::SearchBuilder::AddAndSort>.
+
+=head1 METHODS
+
+=cut
+
+=head2 Table
+
+Returns name of the table where records are stored.
+
+=cut
+
+sub Table { 'ObjectCustomRoles'}
+
+=head2 LimitToCustomRole
+
+Takes id of a L<RT::CustomRole> object and limits this collection.
+
+=cut
+
+sub LimitToCustomRole {
+    my $self = shift;
+    my $id = shift;
+    $self->Limit( FIELD => 'CustomRole', VALUE => $id );
+}
+
+RT::Base->_ImportOverlays();
+
+1;
+
diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index a664100..78782b7 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -96,6 +96,7 @@ RT::ACE->RegisterCacheHandler(sub {
 });
 
 use RT::Groups;
+use RT::CustomRoles;
 use RT::ACL;
 use RT::Interface::Email;
 
@@ -466,7 +467,22 @@ sub TicketTransactionCustomFields {
     return ($cfs);
 }
 
+=head2 CustomRoles
 
+Returns an L<RT::CustomRoles> object containing all queue-specific roles.
+
+=cut
+
+sub CustomRoles {
+    my $self = shift;
+
+    my $roles = RT::CustomRoles->new( $self->CurrentUser );
+    if ( $self->CurrentUserHasRight('SeeQueue') ) {
+        $roles->LimitToObjectId( $self->Id );
+        $roles->ApplySortOrder;
+    }
+    return ($roles);
+}
 
 
 
diff --git a/lib/RT/System.pm b/lib/RT/System.pm
index 9103d14..a11b4f1 100644
--- a/lib/RT/System.pm
+++ b/lib/RT/System.pm
@@ -83,6 +83,7 @@ use Data::GUID;
 __PACKAGE__->AddRight( Admin   => SuperUser           => 'Do anything and everything'); # loc
 __PACKAGE__->AddRight( Staff   => ShowUserHistory     => 'Show history of public user properties'); # loc
 __PACKAGE__->AddRight( Admin   => AdminUsers          => 'Create, modify and delete users'); # loc
+__PACKAGE__->AddRight( Admin   => AdminCustomRoles    => 'Create, modify and delete custom roles'); # loc
 __PACKAGE__->AddRight( Staff   => ModifySelf          => "Modify one's own RT account"); # loc
 __PACKAGE__->AddRight( Staff   => ShowArticlesMenu    => 'Show Articles menu'); # loc
 __PACKAGE__->AddRight( Admin   => ShowConfigTab       => 'Show Admin menu'); # loc
@@ -212,6 +213,27 @@ sub QueueCacheNeedsUpdate {
     }
 }
 
+=head2 CustomRoleCacheNeedsUpdate ( 1 )
+
+Attribute to decide when we need to flush the list of custom roles
+and re-register any changes.  Set when roles are created, enabled/disabled, etc.
+
+If passed a true value, will update the attribute to be the current time.
+
+=cut
+
+sub CustomRoleCacheNeedsUpdate {
+    my $self = shift;
+    my $update = shift;
+
+    if ($update) {
+        return $self->SetAttribute(Name => 'CustomRoleCacheNeedsUpdate', Content => time);
+    } else {
+        my $cache = $self->FirstAttribute('CustomRoleCacheNeedsUpdate');
+        return (defined $cache ? $cache->Content : 0 );
+    }
+}
+
 =head2 AddUpgradeHistory package, data
 
 Adds an entry to the upgrade history database. The package can be either C<RT>
diff --git a/lib/RT/Transaction.pm b/lib/RT/Transaction.pm
index 9eed826..fcddd7c 100644
--- a/lib/RT/Transaction.pm
+++ b/lib/RT/Transaction.pm
@@ -1074,19 +1074,55 @@ sub _FormatUser {
         my $self = shift;
         my $principal = RT::Principal->new($self->CurrentUser);
         $principal->Load($self->NewValue);
-        return ( "[_1] [_2] added", $self->loc($self->Field), $self->_FormatPrincipal($principal));    #loc()
+
+        my $role_name;
+
+        if ($self->Field =~ /^RT::CustomRole-(\d+)$/) {
+            my $role = RT::CustomRole->new($self->CurrentUser);
+            $role->Load($1);
+            $role_name = $role->Name;
+        }
+        else {
+            $role_name = $self->loc($self->Field);
+        }
+
+        return ( "[_1] [_2] added", $role_name, $self->_FormatPrincipal($principal));    #loc()
     },
     DelWatcher => sub {
         my $self = shift;
         my $principal = RT::Principal->new($self->CurrentUser);
         $principal->Load($self->OldValue);
-        return ( "[_1] [_2] deleted", $self->loc($self->Field), $self->_FormatPrincipal($principal));  #loc()
+
+        my $role_name;
+
+        if ($self->Field =~ /^RT::CustomRole-(\d+)$/) {
+            my $role = RT::CustomRole->new($self->CurrentUser);
+            $role->Load($1);
+            $role_name = $role->Name;
+        }
+        else {
+            $role_name = $self->loc($self->Field);
+        }
+
+        return ( "[_1] [_2] deleted", $role_name, $self->_FormatPrincipal($principal));  #loc()
     },
     SetWatcher => sub {
         my $self = shift;
         my $principal = RT::Principal->new($self->CurrentUser);
         $principal->Load($self->NewValue);
-        return ( "[_1] set to [_2]", $self->loc($self->Field), $self->_FormatPrincipal($principal));  #loc()
+
+        my $role_name;
+
+        if ($self->Field =~ /^RT::CustomRole-(\d+)$/) {
+            my $role = RT::CustomRole->new($self->CurrentUser);
+            $role->Load($1);
+            $role_name = $role->Name;
+        }
+        else {
+            $role_name = $self->loc($self->Field);
+        }
+
+        return ( "[_1] set to [_2]", $role_name, $self->_FormatPrincipal($principal));  #loc()
     },
     Subject => sub {
         my $self = shift;
diff --git a/share/html/Admin/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html
new file mode 100644
index 0000000..6e25273
--- /dev/null
+++ b/share/html/Admin/CustomRoles/Modify.html
@@ -0,0 +1,188 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+
+
+<form action="<%RT->Config->Get('WebPath')%>/Admin/CustomRoles/Modify.html" name="ModifyCustomRole" method="post" enctype="multipart/form-data">
+<input type="hidden" class="hidden" name="id" value="<% $Create ? 'new': $RoleObj->Id %>" />
+% $m->callback( CallbackName => 'FormStart', Create => $Create, ARGSRef => \%ARGS );
+<table>
+<tr><td align="right"><&|/l&>Role Name</&>:</td><td colspan="3">
+<input name="Name" value="<% $Create ? "" : $RoleObj->Name || $Name %>" />
+</td></tr>
+
+<tr><td align="right"><&|/l&>Description</&>:</td>
+<td colspan="3"><input name="Description" value="<% $Create ? "" : $RoleObj->Description || $Description || '' %>" size="60" /></td>
+</tr>
+
+<tr><td align="right"><&|/l&>Entry Hint</&>:</td>
+<td colspan="3"><input name="EntryHint" value="<% $Create ? "" : $RoleObj->EntryHint || $EntryHint || '' %>" size="60" /></td>
+</tr>
+
+<tr><td align="right"><input type="checkbox" class="checkbox" id="Multiple" name="Multiple" value="1"
+% if ( $Create || $RoleObj->UnlimitedValues ) {
+checked="checked"
+% }
+% if ( !$Create ) {
+disabled="disabled"
+% }
+ /></td>
+<td colspan="3"><label for="Multiple">
+% if ( $Create ) {
+<&|/l&>Multiple users (Unchecking this box limits this role to a single user. This cannot be modified after creation)</&>
+% } else {
+<&|/l&>Multiple users (This cannot be modified after creation)</&>
+% }
+</label><br />
+
+<input type="hidden" class="hidden" name="SetMultiple" value="1" />
+</td></tr>
+
+<tr><td align="right"><input type="checkbox" class="checkbox" id="Enabled" name="Enabled" value="1" <%$EnabledChecked|n%> /></td>
+<td colspan="3"><label for="Enabled"><&|/l&>Enabled (Unchecking this box disables this custom role)</&></label><br />
+<input type="hidden" class="hidden" name="SetEnabled" value="1" />
+% $m->callback( %ARGS, RoleObj => $RoleObj, results => \@results );
+</td></tr>
+
+</table>
+
+% if ( $Create ) {
+<& /Elements/Submit, Label => loc('Create') &>
+% } else {
+<& /Elements/Submit, Label => loc('Save Changes') &>
+% }
+</form>
+
+
+
+<%INIT>
+my ($title, @results, @no_redirect_results, $Disabled, $EnabledChecked);
+my $RoleObj = RT::CustomRole->new( $session{'CurrentUser'} );
+$RoleObj->Load( $id ) if !$id || $id eq 'new';
+
+$EnabledChecked = 'checked="checked"';
+
+unless ($Create) {
+    if ( defined $id && $id eq 'new' ) {
+        my ($val, $msg) = $RoleObj->Create( Name => $Name );
+        if (!$val) {
+            $Create = 1; # Create failed, so bring us back to step 1
+            push @results, $msg;
+        }
+        else {
+            push @results, loc("Custom role created");
+        }
+    } else {
+        $RoleObj->Load($id) || $RoleObj->Load($Name) || Abort(loc("Couldn't load custom role '[_1]'", $Name));
+    }
+}
+
+if ( $RoleObj->Id ) {
+    $title = loc('Configuration for role [_1]', $RoleObj->Name );
+    my @attribs = qw(Description Name EntryHint Disabled);
+
+    # we just created the role
+    if (!$id || $id eq 'new') {
+        push @attribs, 'MaxValues';
+        if ( $SetMultiple ) {
+            $ARGS{'MaxValues'} = $Multiple ? 0 : 1;
+        }
+    }
+
+    # we're asking about enabled on the page but really care about disabled
+    if ( $SetEnabled ) {
+        $Disabled = $ARGS{'Disabled'} = $Enabled? 0: 1;
+    }
+    $m->callback(
+        CallbackName => 'BeforeUpdate',
+        Role => $RoleObj,
+        AttributesRef => \@attribs,
+        ARGSRef => \%ARGS,
+    );
+
+    my @update_results = UpdateRecordObject(
+        AttributesRef => \@attribs,
+        Object => $RoleObj,
+        ARGSRef => \%ARGS
+    );
+
+    # if we're creating, then don't bother listing updates since it's just
+    # noise for finishing the setup of the newly created record
+    if ($id && $id ne 'new') {
+        push @results, @update_results;
+    }
+
+    $Disabled = $ARGS{'Disabled'} = $Enabled? 0: 1;
+
+    $EnabledChecked = "" if $RoleObj->Disabled;
+} else {
+    $title = loc("Create a custom role");
+}
+
+MaybeRedirectForResults(
+    Actions   => \@results,
+    Arguments => { id => $RoleObj->Id },
+) if $RoleObj->id;
+
+push @results, @no_redirect_results;
+</%INIT>
+<%ARGS>
+$id => undef
+$result => undef
+$Name => undef
+$Create => undef
+$Description => undef
+$EntryHint => undef
+$SetEnabled => undef
+$SetMultiple => undef
+$Multiple => undef
+$Enabled => undef
+</%ARGS>
diff --git a/share/html/Admin/CustomRoles/Objects.html b/share/html/Admin/CustomRoles/Objects.html
new file mode 100644
index 0000000..428ee1b
--- /dev/null
+++ b/share/html/Admin/CustomRoles/Objects.html
@@ -0,0 +1,140 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions &>
+
+<form action="Objects.html" method="post" name="AddRemoveCustomRole">
+<input type="hidden" class="hidden" name="id" value="<% $id %>" />
+<input type="hidden" class="hidden" name="From" value="<% $From || q{} %>" />
+
+<h2><&|/l&>Selected objects</&></h2>
+
+<& /Elements/CollectionList,
+    OrderBy => 'id',
+    Order => 'ASC',
+    %ARGS,
+    Collection => $added,
+    Rows => 0,
+    Page => 1,
+    Format        => $format,
+    DisplayFormat => "'__CheckBox.{RemoveRole-$id}__',". $format,
+    AllowSorting => 0,
+    ShowEmpty    => 0,
+    PassArguments => [
+        qw(id Format Rows Page Order OrderBy),
+    ],
+&>
+
+<h2><&|/l&>Unselected objects</&></h2>
+
+<& /Elements/CollectionList,
+    OrderBy => 'Name',
+    Order   => 'ASC',
+    %ARGS,
+    Collection    => $not_added,
+    Rows          => $rows,
+    Format        => $format,
+    DisplayFormat => "'__CheckBox.{AddRole-". $id ."}__',". $format,
+    AllowSorting  => 1,
+    ShowEmpty     => 0,
+    PassArguments => [
+        qw(id Format Rows Page Order OrderBy),
+    ],
+&>
+
+<& /Elements/Submit, Name => 'Update' &>
+
+</form>
+
+<%ARGS>
+$id     => undef
+$Update => 0
+$From   => undef
+</%ARGS>
+<%INIT>
+my $role = RT::CustomRole->new( $session{'CurrentUser'} );
+$role->Load($id) or Abort(loc("Could not load custom role #[_1]", $id));
+$id = $role->id;
+
+if ($role->Disabled) {
+    Abort(loc("Cannot modify objects of disabled custom role #[_1]", $id));
+}
+
+if ( $Update ) {
+    my (@results);
+    if ( defined (my $del = $ARGS{"RemoveRole-$id"}) ) {
+        foreach my $id ( ref $del ? (@$del) : ($del) ) {
+            my ($status, $msg) = $role->RemoveFromObject( $id );
+            push @results, $msg;
+        }
+    }
+    if ( defined (my $add = $ARGS{"AddRole-$id"}) ) {
+        foreach my $id ( ref $add ? (@$add) : ($add) ) {
+            my ($status, $msg) = $role->AddToObject( $id );
+            push @results, $msg;
+        }
+    }
+    MaybeRedirectForResults(
+        Actions   => \@results,
+        Arguments => {
+            id   => $id,
+            From => $From,
+        },
+    );
+}
+
+my $added = $role->AddedTo;
+my $not_added = $role->NotAddedTo;
+
+my $format = RT->Config->Get('AdminSearchResultFormat')->{'Queues'};
+my $rows = RT->Config->Get('AdminSearchResultRows')->{'Queues'} || 50;
+
+my $title = loc('Modify associated objects for [_1]', $role->Name);
+
+</%INIT>
diff --git a/share/html/Admin/CustomRoles/index.html b/share/html/Admin/CustomRoles/index.html
new file mode 100644
index 0000000..f5f547d
--- /dev/null
+++ b/share/html/Admin/CustomRoles/index.html
@@ -0,0 +1,121 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+
+<h1><%$title%></h1>
+
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Admin/CustomRoles/index.html">
+% foreach my $field( qw(Format Rows Page Order OrderBy) ) {
+%     next unless defined $ARGS{ $field } && length $ARGS{ $field };
+<input type="hidden" name="<% $field %>" value="<% $ARGS{ $field } %>" />
+% }
+
+<select name="SearchField">
+% foreach my $col (qw(Name Description EntryHint)) {
+<option <% $SearchField eq $col ? 'selected="selected"' : '' |n %> value="<% $col %>"><% loc($col) %></option>
+% }
+</select>
+<& /Elements/SelectMatch, Name => 'SearchOp', Default => $SearchOp &>
+<input size="8" name="SearchString" value="<% $SearchString %>" />
+<br />
+
+<input type="checkbox" class="checkbox" id="FindDisabled" name="FindDisabled" value="1" <% $FindDisabled? 'checked="checked"': '' |n%> />
+<label for="FindDisabled"><&|/l&>Include disabled custom roles in listing.</&></label>
+<div align="right"><input type="submit" class="button" value="<&|/l&>Go!</&>" /></div>
+</form>
+
+<p><&|/l&>Select a custom role</&>:</p>
+% unless ( $roles->Count ) {
+<em><&|/l&>No custom roles matching search criteria found.</&></em>
+% } else {
+<& /Elements/CollectionList,
+    OrderBy => 'Name',
+    Order => 'ASC',
+    Rows  => $Rows,
+    %ARGS,
+    Format => $Format,
+    Collection => $roles,
+    AllowSorting => 1,
+    PassArguments => [qw(
+        Format Rows Page Order OrderBy
+        FindDisabled SearchString SearchOp SearchField
+    )],
+&>
+% }
+
+<%INIT>
+my $title = loc("Select a Custom Role");
+
+my $roles = RT::CustomRoles->new($session{'CurrentUser'});
+$roles->FindAllRows if $FindDisabled;
+
+if ( defined $SearchString && length $SearchString ) {
+    $roles->Limit(
+        FIELD    => $SearchField,
+        OPERATOR => $SearchOp,
+        VALUE    => $SearchString,
+    );
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL')."Admin/CustomRoles/Modify.html?id=".$roles->First->id)
+          if $roles->Count == 1;
+} else {
+    $roles->UnLimit;
+}
+
+$Format ||= RT->Config->Get('AdminSearchResultFormat')->{'CustomRoles'};
+my $Rows = RT->Config->Get('AdminSearchResultRows')->{'CustomRoles'} || 50;
+
+</%INIT>
+<%ARGS>
+$FindDisabled => 0
+$Format       => undef
+
+$SearchField   => 'Name'
+$SearchOp      => 'LIKE'
+$SearchString  => ''
+</%ARGS>
diff --git a/share/html/Ticket/Elements/ShowPeople b/share/html/Elements/RT__CustomRole/ColumnMap
similarity index 56%
copy from share/html/Ticket/Elements/ShowPeople
copy to share/html/Elements/RT__CustomRole/ColumnMap
index 52ea2c5..5166118 100644
--- a/share/html/Ticket/Elements/ShowPeople
+++ b/share/html/Elements/RT__CustomRole/ColumnMap
@@ -45,35 +45,66 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<table>
-  <tr>
-    <td class="label"><&|/l&>Owner</&>:</td>
-% my $owner = $Ticket->OwnerObj;
-    <td class="value"><& /Elements/ShowUser, User => $owner, Ticket => $Ticket &>
-    <& /Elements/ShowUserEmailFrequency, User => $owner, Ticket => $Ticket &>
-% $m->callback( User => $owner, Ticket => $Ticket, %ARGS, CallbackName => 'AboutThisUser' );
-    </td>
-  </tr>
-  <tr>
-    <td class="labeltop"><&|/l&>Requestors</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->Requestors, Ticket => $Ticket &></td>
-  </tr>
-% if ( $Ticket->Cc->MembersObj->Count ) {
-  <tr>
-    <td class="labeltop"><&|/l&>Cc</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->Cc, Ticket => $Ticket &></td>
-  </tr>
-% }
-% if ( $Ticket->AdminCc->MembersObj->Count ) {
-  <tr>
-    <td class="labeltop"><&|/l&>AdminCc</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->AdminCc, Ticket => $Ticket &></td>
-  </tr>
-% }
-  <& /Ticket/Elements/ShowCustomFields, Ticket => $Ticket, Grouping => 'People', Table => 0 &>
-</table>
-<%INIT>
-</%INIT>
 <%ARGS>
-$Ticket => undef
+$Name => undef
+$Attr => undef
+$GenericMap => {}
 </%ARGS>
+<%ONCE>
+my $COLUMN_MAP = {
+    Disabled => {
+        title     => 'Status', # loc
+        attribute => 'Disabled',
+        value     => sub { return $_[0]->Disabled ? $_[0]->loc('Disabled') : $_[0]->loc('Enabled') },
+    },
+
+    map(
+        { my $c = $_; $c => {
+            title     => $c, attribute => $c,
+            value     => sub { return $_[0]->$c() },
+        } }
+        qw(Name Description EntryHint)
+    ),
+
+    MaxValues => {
+        title     => 'Number', # loc
+        attribute => 'MaxValues',
+        value     => sub {
+            my $v = $_[0]->MaxValues;
+            return !$v ? $_[0]->loc('Multiple') : $v == 1 ? $_[0]->loc('Single') : $v;
+        },
+    },
+
+    AddedTo => {
+        title     => 'Added', # loc
+        value     => sub {
+            my $collection = $_[0]->AddedTo;
+            return '' unless $collection;
+
+            $collection->RowsPerPage(10);
+
+            my $found = 0;
+            my $res = '';
+            while ( my $record = $collection->Next ) {
+                $res .= ', ' if $res;
+
+                my $id = '';
+                $id = $record->Name if $record->_Accessible('Name','read');
+                $id ||= "#". $record->id;
+                $res .= $id;
+
+                $found++;
+            }
+            $res .= ', ...' if $found >= 10;
+            return $res;
+        },
+
+    },
+};
+
+</%ONCE>
+<%INIT>
+$m->callback( GenericMap => $GenericMap, COLUMN_MAP => $COLUMN_MAP, CallbackName => 'ColumnMap', CallbackOnce => 1 );
+return GetColumnMapEntry( Map => $COLUMN_MAP, Name => $Name, Attribute => $Attr );
+</%INIT>
+
diff --git a/share/html/Ticket/Elements/ShowPeople b/share/html/Elements/SingleUserRoleInput
similarity index 66%
copy from share/html/Ticket/Elements/ShowPeople
copy to share/html/Elements/SingleUserRoleInput
index 52ea2c5..2d86483 100644
--- a/share/html/Ticket/Elements/ShowPeople
+++ b/share/html/Elements/SingleUserRoleInput
@@ -45,35 +45,33 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<table>
-  <tr>
-    <td class="label"><&|/l&>Owner</&>:</td>
-% my $owner = $Ticket->OwnerObj;
-    <td class="value"><& /Elements/ShowUser, User => $owner, Ticket => $Ticket &>
-    <& /Elements/ShowUserEmailFrequency, User => $owner, Ticket => $Ticket &>
-% $m->callback( User => $owner, Ticket => $Ticket, %ARGS, CallbackName => 'AboutThisUser' );
-    </td>
-  </tr>
-  <tr>
-    <td class="labeltop"><&|/l&>Requestors</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->Requestors, Ticket => $Ticket &></td>
-  </tr>
-% if ( $Ticket->Cc->MembersObj->Count ) {
-  <tr>
-    <td class="labeltop"><&|/l&>Cc</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->Cc, Ticket => $Ticket &></td>
-  </tr>
-% }
-% if ( $Ticket->AdminCc->MembersObj->Count ) {
-  <tr>
-    <td class="labeltop"><&|/l&>AdminCc</&>:</td>
-    <td class="value"><& ShowGroupMembers, Group => $Ticket->AdminCc, Ticket => $Ticket &></td>
-  </tr>
-% }
-  <& /Ticket/Elements/ShowCustomFields, Ticket => $Ticket, Grouping => 'People', Table => 0 &>
-</table>
+<& /Elements/EmailInput,
+    Name => $role->GroupType,
+    Size => $Size,
+    ($ShowPlaceholder ? (Placeholder => loc(RT->Nobody->Name)) : ()),
+    ($ShowEntryHint ? (EntryHint => $role->EntryHint) : ()),
+    Default => $Default,
+    Autocomplete => 1,
+    AutocompleteReturn => "Name",
+    AutocompleteNobody => 1,
+&>
+
 <%INIT>
+if (!defined($Default)) {
+    if (!$User && $Ticket) {
+         my $group = $Ticket->RoleGroup($role->GroupType);
+         my $users = $group->UserMembersObj( Recursively => 0 );
+         $User = $users->First;
+    }
+    $Default = (!$User || $User->Id == RT->Nobody->id ? "" : $User->Name);
+}
 </%INIT>
 <%ARGS>
+$role
+$Size => undef
+$Default => undef
+$User => undef
 $Ticket => undef
+$ShowEntryHint => 1
+$ShowPlaceholder => 1
 </%ARGS>
diff --git a/share/html/Elements/Tabs b/share/html/Elements/Tabs
index a55754a..f077c00 100644
--- a/share/html/Elements/Tabs
+++ b/share/html/Elements/Tabs
@@ -97,6 +97,16 @@ my $build_admin_menu = sub {
         $cfs->child( create => title => loc('Create'), path => "/Admin/CustomFields/Modify.html?Create=1" );
     }
 
+    if ( $session{'CurrentUser'}->HasRight( Object => RT->System, Right => 'AdminCustomRoles' ) ) {
+        my $roles = $admin->child( 'custom-roles' =>
+            title       => loc('Custom Roles'),
+            description => loc('Manage custom roles'),
+            path        => '/Admin/CustomRoles/',
+        );
+        $roles->child( select => title => loc('Select'), path => "/Admin/CustomRoles/" );
+        $roles->child( create => title => loc('Create'), path => "/Admin/CustomRoles/Modify.html?Create=1" );
+    }
+
     if ( $session{'CurrentUser'}->HasRight( Object => RT->System, Right => 'ModifyScrips' ) ) {
         my $scrips = $admin->child( 'scrips' =>
             title       => loc('Scrips'),
@@ -247,7 +257,7 @@ my $build_admin_menu = sub {
         path        => '/Admin/Tools/Shredder',
     );
 
-    if ( $request_path =~ m{^/Admin/(Queues|Users|Groups|CustomFields)} ) {
+    if ( $request_path =~ m{^/Admin/(Queues|Users|Groups|CustomFields|CustomRoles)} ) {
         my $type = $1;
         my $tabs = PageMenu();
 
@@ -256,6 +266,7 @@ my $build_admin_menu = sub {
             Users        => loc("Users"),
             Groups       => loc("Groups"),
             CustomFields => loc("Custom Fields"),
+            CustomRoles  => loc("Custom Roles"),
         );
 
         my $section;
@@ -374,6 +385,20 @@ my $build_admin_menu = sub {
         }
     }
 
+    if ( $request_path =~ m{^/Admin/CustomRoles} ) {
+        if ( $DECODED_ARGS->{'id'} && $DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
+            my $id = $DECODED_ARGS->{'id'};
+            my $obj = RT::CustomRole->new( $session{'CurrentUser'} );
+            $obj->Load($id);
+
+            if ( $obj and $obj->id ) {
+                my $tabs = PageMenu();
+                $tabs->child( basics       => title => loc('Basics'),       path => "/Admin/CustomRoles/Modify.html?id=".$id );
+                $tabs->child( 'applies-to' => title => loc('Applies to'),   path => "/Admin/CustomRoles/Objects.html?id=" . $id );
+            }
+        }
+    }
+
     if ( $request_path =~ m{^/Admin/Scrips/} ) {
         if ( $m->request_args->{'id'} && $m->request_args->{'id'} =~ /^\d+$/ ) {
             my $id = $m->request_args->{'id'};
diff --git a/share/html/Search/Bulk.html b/share/html/Search/Bulk.html
index 50f8fcf..cbfe0da 100644
--- a/share/html/Search/Bulk.html
+++ b/share/html/Search/Bulk.html
@@ -94,6 +94,31 @@
 <td class="value"> <& /Elements/EmailInput, Name => "AddAdminCc", Size=> 20, Default => $ARGS{AddAdminCc} &> </td></tr>
 <tr><td class="label"> <&|/l&>Remove AdminCc</&>: </td>
 <td class="value"> <& /Elements/EmailInput, Name => "DeleteAdminCc", Size=> 20, Default => $ARGS{DeleteAdminCc} &> </td></tr>
+
+% my $single_roles = RT::CustomRoles->new($session{CurrentUser});
+% $single_roles->LimitToSingleValue;
+% $single_roles->LimitToObjectId($_) for keys %$seen_queues;
+% while (my $role = $single_roles->Next) {
+<tr>
+<td class="label"> <&|/l, $role->Name &>Make [_1]</&>: </td>
+<td class="value"><& /Elements/SingleUserRoleInput, role => $role, ShowPlaceholder => 0, ShowEntryHint => 0, Size => 20, Default => $ARGS{"RT::CustomRole-" . $role->Id} &></td>
+</tr>
+% }
+
+% my $multi_roles = RT::CustomRoles->new($session{CurrentUser});
+% $multi_roles->LimitToMultipleValue;
+% $multi_roles->LimitToObjectId($_) for keys %$seen_queues;
+% while (my $role = $multi_roles->Next) {
+<tr>
+<td class="label"> <&|/l, $role->Name &>Add [_1]</&>: </td>
+<td class="value"> <& /Elements/EmailInput, Name => "AddRT::CustomRole-" . $role->Id, Size=> 20, Default => $ARGS{"AddRT::CustomRole-" . $role->Id} &> </td>
+</tr>
+<tr>
+<td class="label"> <&|/l, $role->Name &>Remove [_1]</&>: </td>
+<td class="value"> <& /Elements/EmailInput, Name => "DeleteRT::CustomRole-" . $role->Id, Size=> 20, Default => $ARGS{"DeleteRT::CustomRole-" . $role->Id} &> </td>
+</tr>
+% }
+
 </table>
 </td>
 <td valign="top">
diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
index e99f4b9..9c0bdf8 100644
--- a/share/html/Ticket/Create.html
+++ b/share/html/Ticket/Create.html
@@ -97,6 +97,9 @@
                     QueueObj        => $QueueObj,
                 },
             },
+
+            { special => 'roles' },
+
             $QueueObj->SLADisabled ? () : (
             {   name => 'SLA',
                 comp => '/Elements/SelectSLA',
@@ -176,6 +179,28 @@
   </td>
 </tr>
 
+% my $roles = $QueueObj->CustomRoles;
+% $roles->LimitToMultipleValue;
+% while (my $role = $roles->Next) {
+<tr>
+<td class="label">
+<% $role->Name %>:
+</td>
+<td class="value" colspan="5"><& /Elements/EmailInput, Name => $role->GroupType, Size => undef, Default => $ARGS{$role->GroupType}, AutocompleteMultiple => 1 &></td>
+</tr>
+
+% if ($role->EntryHint) {
+<tr>
+  <td class="label"> </td>
+  <td class="comment" colspan="5">
+    <i><font size="-2">
+      <% $role->EntryHint %>
+    </font></i>
+  </td>
+</tr>
+% }
+% }
+
 <& /Elements/EditCustomFields,
     %ARGS,
     Object => $ticket,
diff --git a/share/html/Ticket/Elements/EditBasics b/share/html/Ticket/Elements/EditBasics
index b478cde..2df24b6 100644
--- a/share/html/Ticket/Elements/EditBasics
+++ b/share/html/Ticket/Elements/EditBasics
@@ -51,6 +51,7 @@ $QueueObj => undef
 @fields => ()
 $InTable => 0
 %defaults => ()
+$ExcludeCustomRoles => 0
 </%ARGS>
 <%INIT>
 if ($TicketObj) {
@@ -90,6 +91,9 @@ unless ( @fields ) {
                 DefaultValue => 0,
             }
         },
+
+        { special => 'roles' },
+
         $QueueObj->SLADisabled ? () : (
         {   name => 'SLA',
             comp => '/Elements/SelectSLA',
@@ -131,6 +135,27 @@ unless ( @fields ) {
     );
 }
 
+my @role_fields;
+
+unless ($ExcludeCustomRoles) {
+    my $roles = $QueueObj->CustomRoles;
+    $roles->LimitToSingleValue;
+    while (my $role = $roles->Next) {
+        push @role_fields, {
+            name => $role->Name,
+            comp => '/Elements/SingleUserRoleInput',
+            args => {
+                role    => $role,
+                Ticket  => $TicketObj,
+                Default => $defaults{$role->GroupType},
+            }
+        };
+    }
+}
+
+# inflate the marker for custom roles into the field specs for each one
+ at fields = map { ($_->{special}||'') eq 'roles' ? @role_fields : $_ } @fields;
+
 $m->callback( CallbackName => 'MassageFields', %ARGS, TicketObj => $TicketObj, Fields => \@fields );
 
 # Process the field list, skipping if html is provided and running the
diff --git a/share/html/Ticket/Elements/EditPeople b/share/html/Ticket/Elements/EditPeople
index 3c840f7..45ff3c1 100644
--- a/share/html/Ticket/Elements/EditPeople
+++ b/share/html/Ticket/Elements/EditPeople
@@ -63,8 +63,27 @@
         GroupString => $GroupString, GroupOp => $GroupOp,
         GroupField => $GroupField, PrivilegedOnly => $PrivilegedOnly &> 
 </td><td valign="top">
-<h3><&|/l&>Owner</&></h3>
-<&|/l&>Owner</&>: <& /Elements/SelectOwner, Name => 'Owner', QueueObj => $Ticket->QueueObj, TicketObj => $Ticket, Default => $Ticket->OwnerObj->Id, DefaultValue => 0&>
+<h3><&|/l&>People</&></h3>
+<table>
+
+<tr>
+  <td class="label"><&|/l&>Owner</&>:</td>
+  <td class="value"><& /Elements/SelectOwner, Name => 'Owner', QueueObj => $Ticket->QueueObj, TicketObj => $Ticket, Default => $Ticket->OwnerObj->Id, DefaultValue => 0&></td>
+</tr>
+
+% my @role_fields;
+% my $single_roles = $Ticket->QueueObj->CustomRoles;
+% $single_roles->LimitToSingleValue;
+% while (my $role = $single_roles->Next) {
+<tr>
+  <td class="label"><% $role->Name %>:</td>
+  <td class="value"><& /Elements/SingleUserRoleInput, role => $role, Ticket => $Ticket &></td>
+</tr>
+
+% }
+
+</table>
+
 <h3><&|/l&>Current watchers</&></h3>
 <i><&|/l&>(Check box to delete)</&></i><br />
 
@@ -85,6 +104,16 @@
   <td class="value"><& EditWatchers, TicketObj => $Ticket, Watchers => $Ticket->AdminCc &></td>
 </tr>
 
+% my $multi_roles = $Ticket->QueueObj->CustomRoles;
+% $multi_roles->LimitToMultipleValue;
+% while (my $role = $multi_roles->Next) {
+% my $group = $Ticket->RoleGroup($role->GroupType);
+<tr>
+  <td class="label"><% $role->Name %>:</td>
+  <td class="value"><& EditWatchers, TicketObj => $Ticket, Watchers => $group &></td>
+</tr>
+% }
+
 <& /Elements/EditCustomFields, Object => $Ticket, Grouping => 'People', InTable => 1 &>
 
 </table>
diff --git a/share/html/Ticket/Elements/ShowPeople b/share/html/Ticket/Elements/ShowPeople
index 52ea2c5..5efa193 100644
--- a/share/html/Ticket/Elements/ShowPeople
+++ b/share/html/Ticket/Elements/ShowPeople
@@ -54,6 +54,23 @@
 % $m->callback( User => $owner, Ticket => $Ticket, %ARGS, CallbackName => 'AboutThisUser' );
     </td>
   </tr>
+
+% my $single_roles = $Ticket->QueueObj->CustomRoles;
+% $single_roles->LimitToSingleValue;
+% while (my $role = $single_roles->Next) {
+%     my $group = $Ticket->RoleGroup($role->GroupType);
+%     my $users = $group->UserMembersObj( Recursively => 0 );
+
+%# $users can be empty for tickets created before the custom role is added to the queue,
+%# so fall back to nobody
+
+%     my $user = $users->First || RT->Nobody;
+  <tr>
+    <td class="label"><% $role->Name %>:</td>
+    <td class="value"><& /Elements/ShowUser, User => $user, Ticket => $Ticket &></td>
+  </tr>
+% }
+
   <tr>
     <td class="labeltop"><&|/l&>Requestors</&>:</td>
     <td class="value"><& ShowGroupMembers, Group => $Ticket->Requestors, Ticket => $Ticket &></td>
@@ -70,6 +87,16 @@
     <td class="value"><& ShowGroupMembers, Group => $Ticket->AdminCc, Ticket => $Ticket &></td>
   </tr>
 % }
+
+% my $multi_roles = $Ticket->QueueObj->CustomRoles;
+% $multi_roles->LimitToMultipleValue;
+% while (my $role = $multi_roles->Next) {
+  <tr>
+    <td class="labeltop"><% $role->Name %>:</td>
+    <td class="value"><& ShowGroupMembers, Group => $Ticket->RoleGroup($role->GroupType), Ticket => $Ticket &></td>
+  </tr>
+% }
+
   <& /Ticket/Elements/ShowCustomFields, Ticket => $Ticket, Grouping => 'People', Table => 0 &>
 </table>
 <%INIT>
diff --git a/share/html/Ticket/ModifyAll.html b/share/html/Ticket/ModifyAll.html
index 7011ccb..021dd65 100644
--- a/share/html/Ticket/ModifyAll.html
+++ b/share/html/Ticket/ModifyAll.html
@@ -58,7 +58,7 @@
 <input type="hidden" class="hidden" name="Token" value="<% $ARGS{'Token'} %>" />
 
 <&| /Widgets/TitleBox, title => loc('Modify ticket # [_1]', $Ticket->Id), class=>'ticket-info-basics' &>
-<& Elements/EditBasics, TicketObj => $Ticket, defaults => \%ARGS &>
+<& Elements/EditBasics, TicketObj => $Ticket, defaults => \%ARGS, ExcludeCustomRoles => 1 &>
 <& /Elements/EditCustomFields, Object => $Ticket, Grouping => 'Basics' &>
 </&>
 
diff --git a/share/html/Ticket/Update.html b/share/html/Ticket/Update.html
index 52dde3b..5cf484d 100644
--- a/share/html/Ticket/Update.html
+++ b/share/html/Ticket/Update.html
@@ -106,6 +106,7 @@
                 Default      => $ARGS{'Owner'}
             }
         },
+        { special => 'roles' },
         {   name => 'Worked',
             comp => '/Elements/EditTimeValue',
             args => {

commit 307dc7431c91f43662369979e8336bcc711cc937
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 04:02:42 2015 +0000

    @CustomRoles in initialdata, support CustomRole in @ACL

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index f5a5d50..434ac68 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -829,9 +829,9 @@ sub InsertData {
 
     # Slurp in stuff to insert from the datafile. Possible things to go in here:-
     our (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
-           @Templates, @CustomFields, @Scrips, @Attributes, @Initial, @Final);
+           @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final);
     local (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
-           @Templates, @CustomFields, @Scrips, @Attributes, @Initial, @Final);
+           @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final);
 
     local $@;
     $RT::Logger->debug("Going to load '$datafile' data file");
@@ -1122,6 +1122,36 @@ sub InsertData {
 
         $RT::Logger->debug("done.");
     }
+
+    if ( @CustomRoles ) {
+        $RT::Logger->debug("Creating custom roles...");
+        for my $item ( @CustomRoles ) {
+            my $attributes = delete $item->{ Attributes };
+            my $apply_to = delete $item->{'ApplyTo'};
+
+            my $new_entry = RT::CustomRole->new( RT->SystemUser );
+
+            my ( $ok, $msg ) = $new_entry->Create(%$item);
+            if (!$ok) {
+                $RT::Logger->error($msg);
+                next;
+            }
+
+            if ($apply_to) {
+                $apply_to = [ $apply_to ] unless ref $apply_to;
+                for my $name ( @{ $apply_to } ) {
+                    my ($ok, $msg) = $new_entry->AddToObject($name);
+                    $RT::Logger->error( $msg ) if !$ok;
+                }
+            }
+
+            $_->{Object} = $new_entry for @{$attributes || []};
+            push @Attributes, @{$attributes || []};
+        }
+
+        $RT::Logger->debug("done.");
+    }
+
     if ( @ACL ) {
         $RT::Logger->debug("Creating ACL...");
         for my $item (@ACL) {
@@ -1160,6 +1190,12 @@ sub InsertData {
 
             # Group rights or user rights?
             if ( $item->{'GroupDomain'} ) {
+                if (my $role_name = delete $item->{CustomRole}) {
+                    my $role = RT::CustomRole->new(RT->SystemUser);
+                    $role->Load($role_name);
+                    $item->{'GroupType'} = $role->GroupType;
+                }
+
                 $princ = RT::Group->new(RT->SystemUser);
                 if ( $item->{'GroupDomain'} eq 'UserDefined' ) {
                   $princ->LoadUserDefinedGroup( $item->{'GroupId'} );

commit 4d3a3404c00811ef44f81021cd6e68150f466847
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 04:03:23 2015 +0000

    Add custom roles to search builder

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 0b90d83..6c1923e 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -83,6 +83,7 @@ use RT::SQL;
 sub Table { 'Tickets'}
 
 use RT::CustomFields;
+use RT::CustomRoles;
 
 __PACKAGE__->RegisterCustomFieldJoin(@$_) for
     [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
@@ -149,6 +150,7 @@ our %FIELD_METADATA = (
     QueueCc          => [ 'WATCHERFIELD'    => 'Cc'      => 'Queue', ], #loc_left_pair
     QueueAdminCc     => [ 'WATCHERFIELD'    => 'AdminCc' => 'Queue', ], #loc_left_pair
     QueueWatcher     => [ 'WATCHERFIELD'    => undef     => 'Queue', ], #loc_left_pair
+    CustomRole       => [ 'WATCHERFIELD' ], # loc_left_pair
     CustomFieldValue => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     CustomField      => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     CF               => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
@@ -997,6 +999,39 @@ sub _TransContentLimit {
     $self->_CloseParen;
 }
 
+=head2 _CustomRoleDecipher
+
+Try and turn a custom role descriptor (e.g. C<CustomRole.{Engineer}>) into
+(role, column, original name).
+
+=cut
+
+sub _CustomRoleDecipher {
+    my ($self, $string) = @_;
+
+    my ($field, $column) = ($string =~ /^\{(.+)\}(?:\.(\w+))?$/);
+
+    my $role;
+
+    if ( $field =~ /\D/ ) {
+        my $roles = RT::CustomRoles->new( $self->CurrentUser );
+        $roles->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
+
+        # custom roles are named uniquely, but just in case there are
+        # multiple matches, bail out as we don't know which one to use
+        $role = $roles->First;
+        if ( $role ) {
+            $role = undef if $roles->Next;
+        }
+    }
+    else {
+        $role = RT::CustomField->new( $self->CurrentUser );
+        $role->Load( $field );
+    }
+
+    return ($role, $column, $field);
+}
+
 =head2 _WatcherLimit
 
 Handle watcher limits.  (Requestor, CC, etc..)
@@ -1020,6 +1055,12 @@ sub _WatcherLimit {
     my $class = $meta->[2] || 'Ticket';
     my $column = $rest{SUBKEY};
 
+    if ($field eq 'CustomRole') {
+        my ($role, $col, $original_name) = $self->_CustomRoleDecipher( $column );
+        $column = $col || 'id';
+        $type = $role ? $role->GroupType : $original_name;
+    }
+
     # Bail if the subfield is not allowed
     if (    $column
         and not grep { $_ eq $column } @{$SEARCHABLE_SUBFIELDS{'User'}})
@@ -1324,6 +1365,12 @@ sub OrderByCols {
             my $class = $meta->[2] || 'Ticket';
             my $column = $subkey;
 
+            if ($field eq 'CustomRole') {
+                my ($role, $col, $original_name) = $self->_CustomRoleDecipher( $column );
+                $column = $col || 'id';
+                $type = $role ? $role->GroupType : $original_name;
+            }
+
             # cache alias as we want to use one alias per watcher type for sorting
             my $cache_key = join "-", $type, $class;
             my $users = $self->{_sql_u_watchers_alias_for_sort}{ $cache_key };
diff --git a/share/html/Elements/ColumnMap b/share/html/Elements/ColumnMap
index a5abd54..14c6b0b 100644
--- a/share/html/Elements/ColumnMap
+++ b/share/html/Elements/ColumnMap
@@ -142,6 +142,37 @@ $WCOLUMN_MAP = $COLUMN_MAP = {
             return @values;
         },
     },
+    CustomRole => {
+        attribute => sub { return shift @_ },
+        title     => sub { return pop @_ },
+        value     => sub {
+            my $object = shift;
+            my $role_name = pop;
+
+            my $role_type = do {
+                # Cache the role object on a per-request basis, to avoid
+                # having to load it for every row
+                my $key = "RT::CustomRole-" . $role_name;
+
+                my $role_type = $m->notes($key);
+                if (!$role_type) {
+                    my $role_obj = RT::CustomRole->new($object->CurrentUser);
+                    $role_obj->Load($role_name);
+
+                    RT->Logger->notice("Unable to load custom role $role_name")
+                        unless $role_obj->Id;
+
+                    $role_type = $role_obj->GroupType;
+                    $m->notes($key, $role_type);
+                }
+
+                $role_type;
+            };
+
+            return if !$role_type;
+            return \($m->scomp("/Elements/ShowPrincipal", Object => $object->RoleGroup($role_type) ) );
+        },
+    },
 
     CheckBox => {
         title => sub {
@@ -220,7 +251,8 @@ my $RecordClass = $Class;
 $RecordClass =~ s/_/:/g;
 if ($RecordClass->DOES("RT::Record::Role::Roles")) {
     unless ($ROLE_MAP->{$RecordClass}) {
-        for my $role ($RecordClass->Roles) {
+        # UserDefined => 1 is handled by the CustomRole mapping
+        for my $role ($RecordClass->Roles(UserDefined => 0)) {
             my $attrs = $RecordClass->Role($role);
             $ROLE_MAP->{$RecordClass}{$role} = {
                 title => $role,
diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index 31b5487..d52eb59 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -200,7 +200,7 @@ my $cf_field_names =
 
 # Try to find if we're adding a clause
 foreach my $arg ( keys %ARGS ) {
-    next unless $arg =~ m/^ValueOf(\w+|($cf_field_names).\{.*?\})$/
+    next unless $arg =~ m/^ValueOf(\w+|($cf_field_names).\{.*?\}|CustomRole.\{.*?\})$/
                 && ( ref $ARGS{$arg} eq "ARRAY"
                      ? grep $_ ne '', @{ $ARGS{$arg} }
                      : $ARGS{$arg} ne '' );
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index 7947947..c91c3ac 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -114,6 +114,18 @@ while ( my $CustomField = $CustomFields->Next ) {
     push @fields, "CustomField.{" . $CustomField->Name . "}";
 }
 
+my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
+foreach my $id (keys %queues) {
+    # Gotta load up the $queue object, since queues get stored by name now.
+    my $queue = RT::Queue->new($session{'CurrentUser'});
+    $queue->Load($id);
+    next unless $queue->Id;
+    $CustomRoles->LimitToObjectId($queue->Id);
+}
+while ( my $Role = $CustomRoles->Next ) {
+    push @fields, "CustomRole.{" . $Role->Name . "}";
+}
+
 $m->callback( Fields => \@fields, ARGSRef => \%ARGS );
 
 my ( @seen);
diff --git a/share/html/Search/Elements/EditSort b/share/html/Search/Elements/EditSort
index 2707bde..b828332 100644
--- a/share/html/Search/Elements/EditSort
+++ b/share/html/Search/Elements/EditSort
@@ -116,6 +116,14 @@ $fields{ $_ . '.EmailAddress' } = $_ . '.EmailAddress'
 my @cfs = grep /^CustomField/, @{$ARGS{AvailableColumns}};
 $fields{$_} = $_ for @cfs;
 
+# Add all available CustomRoles to the list of sortable columns.
+my @roles = grep /^CustomRole/, @{$ARGS{AvailableColumns}};
+for my $role (@roles) {
+    my ($label) = $role =~ /^CustomRole.{(.*)}$/;
+    my $value = $role;
+    $fields{$label . '.EmailAddress' } = $value . '.EmailAddress';
+}
+
 # Add PAW sort
 $fields{'Custom.Ownership'} = 'Custom.Ownership';
 
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickCriteria
index e248422..b86451b 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -52,6 +52,7 @@
 
 % $m->callback( %ARGS, CallbackName => "BeforeBasics" );
 <& PickBasics, queues => \%queues &>
+<& PickCustomRoles, queues => \%queues &>
 <& PickTicketCFs, queues => \%queues &>
 <& PickObjectCFs, Class => 'Transaction', queues => \%queues &>
 <& PickObjectCFs, Class => 'Queue', queues => \%queues &>
diff --git a/share/html/Search/Elements/SelectPersonType b/share/html/Search/Elements/PickCustomRoles
similarity index 66%
copy from share/html/Search/Elements/SelectPersonType
copy to share/html/Search/Elements/PickCustomRoles
index 0fc541b..7a14eb0 100644
--- a/share/html/Search/Elements/SelectPersonType
+++ b/share/html/Search/Elements/PickCustomRoles
@@ -45,40 +45,46 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<select id="<%$Name%>" name="<%$Name%>">
-% if ($AllowNull) {
-<option value="">-</option>
-% }
-% for my $option (@types) {
-%  if ($Suffix) {
-<option value="<% $option %><% $Suffix %>"<%$option eq $Default && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc('Group') %></option>
-%   next;
-%  }
-%  foreach my $subtype (@subtypes) {
-<option value="<%"$option.$subtype"%>"<%$option eq $Default && $subtype eq 'EmailAddress' && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc($subtype) %></option>
-%  }
-% }
-</select>
-
+<%ARGS>
+%queues => ()
+</%ARGS>
 <%INIT>
-my @types;
-if ($Scope =~ /queue/) {
-   @types = qw(Cc AdminCc);
-}
-elsif ($Suffix eq 'Group') {
-   @types = qw(Owner Requestor Cc AdminCc Watcher);
+my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
+foreach my $id (keys %queues) {
+    # Gotta load up the $queue object, since queues get stored by name now.
+    my $queue = RT::Queue->new($session{'CurrentUser'});
+    $queue->Load($id);
+    next unless $queue->Id;
+    $CustomRoles->LimitToObjectId($queue->Id);
 }
-else { 
-   @types = qw(Requestor Cc AdminCc Watcher Owner QueueCc QueueAdminCc QueueWatcher);
+$m->callback(
+    CallbackName => 'MassageCustomRoles',
+    CustomRoles  => $CustomRoles,
+);
+
+my @lines;
+while ( my $Role = $CustomRoles->Next ) {
+    my $name = "CustomRole.{" . $Role->Name . "}";
+    my %line = (
+        Name => $name,
+        Field => {
+            Type => 'component',
+            Path => 'SelectPersonType',
+            Arguments => { Role => $Role, Default => $name },
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 20 },
+    );
+
+    push @lines, \%line;
 }
 
-my @subtypes = @{ $RT::Tickets::SEARCHABLE_SUBFIELDS{'User'} };
+$m->callback( Conditions => \@lines, Queues => \%queues );
 
 </%INIT>
-<%ARGS>
-$AllowNull => 1
-$Suffix => ''
-$Default=>undef
-$Scope => 'ticket'
-$Name => 'WatcherType'
-</%ARGS>
+% foreach( @lines ) {
+<& ConditionRow, Condition => $_ &>
+% }
diff --git a/share/html/Search/Elements/SelectPersonType b/share/html/Search/Elements/SelectPersonType
index 0fc541b..b3625cd 100644
--- a/share/html/Search/Elements/SelectPersonType
+++ b/share/html/Search/Elements/SelectPersonType
@@ -50,19 +50,29 @@
 <option value="">-</option>
 % }
 % for my $option (@types) {
+% my ($value, $label) = ($option, $option);
+% if (ref($option)) {
+%     ($value, $label) = @$option;
+% }
+
 %  if ($Suffix) {
-<option value="<% $option %><% $Suffix %>"<%$option eq $Default && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc('Group') %></option>
+<option value="<% $value %><% $Suffix %>"<%$value eq $Default && qq[ selected="selected"] |n %> ><% loc($label) %> <% loc('Group') %></option>
 %   next;
 %  }
 %  foreach my $subtype (@subtypes) {
-<option value="<%"$option.$subtype"%>"<%$option eq $Default && $subtype eq 'EmailAddress' && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc($subtype) %></option>
+<option value="<%"$value.$subtype"%>"<%$value eq $Default && $subtype eq 'EmailAddress' && qq[ selected="selected"] |n %> ><% loc($label) %> <% loc($subtype) %></option>
 %  }
 % }
 </select>
 
 <%INIT>
 my @types;
-if ($Scope =~ /queue/) {
+if ($Role) {
+   @types = (
+      [ "CustomRole.{" . $Role->Name . "}", $Role->Name ],
+   );
+}
+elsif ($Scope =~ /queue/) {
    @types = qw(Cc AdminCc);
 }
 elsif ($Suffix eq 'Group') {
@@ -81,4 +91,5 @@ $Suffix => ''
 $Default=>undef
 $Scope => 'ticket'
 $Name => 'WatcherType'
+$Role => undef
 </%ARGS>

commit 670ddf8b97d4a83da0ff494c52d93b5e5fe1103f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Oct 27 04:03:59 2015 +0000

    Ensure custom role name uniqueness in rt-validator

diff --git a/sbin/rt-validator.in b/sbin/rt-validator.in
index 16432a3..a6dc4b7 100644
--- a/sbin/rt-validator.in
+++ b/sbin/rt-validator.in
@@ -382,6 +382,22 @@ push @CHECKS, 'User Defined Group Name uniqueness' => sub {
     );
 };
 
+push @CHECKS, 'Custom Role Name uniqueness' => sub {
+    return check_uniqueness(
+        'CustomRoles',
+        columns         => ['Name'],
+        action          => sub {
+            return unless prompt(
+                'Rename', "Found a custom role with a non-unique Name."
+            );
+
+            my $id = shift;
+            my %cols = @_;
+            update_records('CustomRoles', { id => $id }, { Name => join('-', $cols{'Name'}, $id) });
+        },
+    );
+};
+
 push @CHECKS, 'GMs -> Groups, Members' => sub {
     my $msg = "A record in GroupMembers references an object that doesn't exist."
         ." Maybe you deleted a group or principal directly from the database?"

-----------------------------------------------------------------------


More information about the rt-commit mailing list