[Rt-commit] rt branch, 4.4/asset-custom-roles, created. rt-4.4.4-460-gd95fe2d665

? sunnavy sunnavy at bestpractical.com
Thu May 20 17:11:12 EDT 2021


The branch, 4.4/asset-custom-roles has been created
        at  d95fe2d665bb12ba6af73c1607b409aee856d3fa (commit)

- Log -----------------------------------------------------------------
commit 614bf65559f281fc8050bd9dd141971d50627202
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Apr 18 14:32:30 2017 +0000

    Add CustomRoleObj method for loading by GroupType
    
    With this we can easily go from the output of ->Roles to an RT::CustomRole
    object.

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 4d4723a66a..bff2c46acd 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -795,5 +795,25 @@ sub LabelForRole {
     return $role->{Name};
 }
 
+=head2 CustomRoleObj
+
+Returns the L<RT::CustomRole> object for this role if and only if it's
+backed by a custom role. If it's a core role (e.g. Ticket Requestors),
+returns C<undef>.
+
+=cut
+
+sub CustomRoleObj {
+    my $self = shift;
+    my $name = shift;
+
+    if (my ($id) = $name =~ /^RT::CustomRole-(\d+)$/) {
+        my $role = RT::CustomRole->new($self->CurrentUser);
+        $role->Load($id);
+        return $role;
+    }
+
+    return undef;
+}
 
 1;

commit 28e3352c0a7b2f15f03441bf03894185743717ff
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 15:48:07 2017 +0000

    Add RT::Asset->RoleAddresses
    
    This mirrors RT::Ticket->RoleAddresses

diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
index f912f650f7..fbd815e494 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -519,6 +519,23 @@ sub RoleGroup {
     }
 }
 
+=head2 RoleAddresses
+
+Takes a role name and returns a string of all the email addresses for
+users in that role
+
+=cut
+
+sub RoleAddresses {
+    my $self = shift;
+    my $role = shift;
+
+    if ( $self->CurrentUserCanSee ) {
+        return $self->RoleGroup($role)->MemberEmailAddressesAsString;
+    }
+    return undef;
+}
+
 =head1 INTERNAL METHODS
 
 Public methods, but you shouldn't need to call these unless you're

commit 9e0411e9509618caa539150b4b557e70fe9f9b4c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:26:26 2017 +0000

    Factor out a LookupType role from CustomFields
    
    This will be added to CustomRoles to support custom roles on assets
    and other record types.
    
    This generalizes and deprecates /Admin/Elements/SelectCustomFieldLookupType in
    favor of a new /Admin/Elements/SelectLookupType. That way we can use it on the
    CustomRole Modify page

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 0f8b52c7ed..bfa88703c2 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -57,7 +57,8 @@ use Scalar::Util 'blessed';
 use base 'RT::Record';
 
 use Role::Basic 'with';
-with "RT::Record::Role::Rights";
+with "RT::Record::Role::Rights",
+     "RT::Record::Role::LookupType";
 
 sub Table {'CustomFields'}
 
@@ -209,7 +210,6 @@ our %FieldTypes = (
 
 
 my %BUILTIN_GROUPINGS;
-my %FRIENDLY_LOOKUP_TYPES = ();
 
 __PACKAGE__->RegisterLookupType( 'RT::Queue-RT::Ticket' => "Tickets", );    #loc
 __PACKAGE__->RegisterLookupType( 'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions", ); #loc
@@ -1386,120 +1386,6 @@ sub SetLookupType {
     return $self->_Set(Field => 'LookupType', Value =>$lookup);
 }
 
-=head2 LookupTypes
-
-Returns an array of LookupTypes available
-
-=cut
-
-
-sub LookupTypes {
-    my $self = shift;
-    return sort keys %FRIENDLY_LOOKUP_TYPES;
-}
-
-=head2 FriendlyLookupType
-
-Returns a localized description of the type of this custom field
-
-=cut
-
-sub FriendlyLookupType {
-    my $self = shift;
-    my $lookup = shift || $self->LookupType;
-
-    return ($self->loc( $FRIENDLY_LOOKUP_TYPES{$lookup} ))
-        if defined $FRIENDLY_LOOKUP_TYPES{$lookup};
-
-    my @types = map { s/^RT::// ? $self->loc($_) : $_ }
-      grep { defined and length }
-      split( /-/, $lookup )
-      or return;
-
-    state $LocStrings = [
-        "[_1] objects",            # loc
-        "[_1]'s [_2] objects",        # loc
-        "[_1]'s [_2]'s [_3] objects",   # loc
-    ];
-    return ( $self->loc( $LocStrings->[$#types], @types ) );
-}
-
-=head1 RecordClassFromLookupType
-
-Returns the type of Object referred to by ObjectCustomFields' ObjectId column
-
-Optionally takes a LookupType to use instead of using the value on the loaded
-record.  In this case, the method may be called on the class instead of an
-object.
-
-=cut
-
-sub RecordClassFromLookupType {
-    my $self = shift;
-    my $type = shift || $self->LookupType;
-    my ($class) = ($type =~ /^([^-]+)/);
-    unless ( $class ) {
-        if (blessed($self) and $self->LookupType eq $type) {
-            $RT::Logger->error(
-                "Custom Field #". $self->id
-                ." has incorrect LookupType '$type'"
-            );
-        } else {
-            RT->Logger->error("Invalid LookupType passed as argument: $type");
-        }
-        return undef;
-    }
-    return $class;
-}
-
-=head1 ObjectTypeFromLookupType
-
-Returns the ObjectType used in ObjectCustomFieldValues rows for this CF
-
-Optionally takes a LookupType to use instead of using the value on the loaded
-record.  In this case, the method may be called on the class instead of an
-object.
-
-=cut
-
-sub ObjectTypeFromLookupType {
-    my $self = shift;
-    my $type = shift || $self->LookupType;
-    my ($class) = ($type =~ /([^-]+)$/);
-    unless ( $class ) {
-        if (blessed($self) and $self->LookupType eq $type) {
-            $RT::Logger->error(
-                "Custom Field #". $self->id
-                ." has incorrect LookupType '$type'"
-            );
-        } else {
-            RT->Logger->error("Invalid LookupType passed as argument: $type");
-        }
-        return undef;
-    }
-    return $class;
-}
-
-sub CollectionClassFromLookupType {
-    my $self = shift;
-    my $record_class = shift || $self->RecordClassFromLookupType;
-
-    return undef unless $record_class;
-
-    my $collection_class;
-    if ( UNIVERSAL::can($record_class.'Collection', 'new') ) {
-        $collection_class = $record_class.'Collection';
-    } elsif ( UNIVERSAL::can($record_class.'es', 'new') ) {
-        $collection_class = $record_class.'es';
-    } elsif ( UNIVERSAL::can($record_class.'s', 'new') ) {
-        $collection_class = $record_class.'s';
-    } else {
-        $RT::Logger->error("Can not find a collection class for record class '$record_class'");
-        return undef;
-    }
-    return $collection_class;
-}
-
 =head2 Groupings
 
 Returns a (sorted and lowercased) list of the groupings in which this custom
@@ -1599,20 +1485,6 @@ sub RegisterBuiltInGroupings {
     $BUILTIN_GROUPINGS{''} = { map { %$_ } values %BUILTIN_GROUPINGS  };
 }
 
-=head1 IsOnlyGlobal
-
-Certain custom fields (users, groups) should only be added globally;
-codify that set here for reference.
-
-=cut
-
-sub IsOnlyGlobal {
-    my $self = shift;
-
-    return ($self->LookupType =~ /^RT::(?:Group|User)/io);
-
-}
-
 =head1 AddedTo
 
 Returns collection with objects this custom field is added to.
@@ -2078,31 +1950,6 @@ sub CurrentUserCanSee {
     return 0;
 }
 
-=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
-
-Tell RT that a certain object accepts custom fields via a lookup type and
-provide a friendly name for such CFs.
-
-Examples:
-
-    'RT::Queue-RT::Ticket'                 => "Tickets",                # loc
-    'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions",    # loc
-    'RT::User'                             => "Users",                  # loc
-    'RT::Group'                            => "Groups",                 # loc
-    'RT::Queue'                            => "Queues",                 # loc
-
-This is a class method. 
-
-=cut
-
-sub RegisterLookupType {
-    my $self = shift;
-    my $path = shift;
-    my $friendly_name = shift;
-
-    $FRIENDLY_LOOKUP_TYPES{$path} = $friendly_name;
-}
-
 =head2 IncludeContentForValue [VALUE] (and SetIncludeContentForValue)
 
 Gets or sets the  C<IncludeContentForValue> for this custom field. RT
diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
new file mode 100644
index 0000000000..f15487d26c
--- /dev/null
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -0,0 +1,251 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 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 }}}
+
+package RT::Record::Role::LookupType;
+
+use strict;
+use warnings;
+use 5.010;
+
+use Role::Basic;
+use Scalar::Util qw(blessed);
+
+=head1 NAME
+
+RT::Record::Role::LookupType - Common methods for records which have a LookupType
+
+=head1 DESCRIPTION
+
+Certain records, like custom fields, can be applied to different types of
+records (tickets, transactions, groups, users, etc). This role implements
+such I<LookupType> concerns.
+
+This role does not manage concerns relating to specifying which records
+of a class (as in L<RT::ObjectCustomField>).
+
+=head1 REQUIRES
+
+=head2 L<RT::Record::Role>
+
+=head2 LookupType
+
+A C<LookupType> method which returns this record's lookup type is required.
+Currently unenforced at compile-time due to poor interactions with
+L<DBIx::SearchBuilder::Record/AUTOLOAD>.  You'll hit run-time errors if
+this method isn't available in consuming classes, however.
+
+=cut
+
+with 'RT::Record::Role';
+
+=head1 PROVIDES
+
+=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
+
+Tell RT that a certain object accepts records of this role via a lookup
+type and provide a friendly name for them.
+
+Examples:
+
+    'RT::Queue-RT::Ticket'                 => "Tickets",                # loc
+    'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions",    # loc
+    'RT::User'                             => "Users",                  # loc
+    'RT::Group'                            => "Groups",                 # loc
+    'RT::Queue'                            => "Queues",                 # loc
+
+This is a class method.
+
+=cut
+
+my %REGISTRY = ();
+
+sub RegisterLookupType {
+    my $class = shift;
+    my $path = shift;
+    my $friendly_name = shift;
+
+    die "RegisterLookupType is a class method" if blessed($class);
+
+    $REGISTRY{$class}{$path} = $friendly_name;
+}
+
+=head2 LookupTypes
+
+Returns an array of LookupTypes available for this record or class
+
+=cut
+
+sub LookupTypes {
+    my $self = shift;
+    my $class = blessed($self) || $self;
+    return sort keys %{ $REGISTRY{ $class } };
+}
+
+=head2 FriendlyLookupType
+
+Returns a localized description of the LookupType of this record
+
+=cut
+
+sub FriendlyLookupType {
+    my $self = shift;
+    my $lookup = shift || $self->LookupType;
+
+    my $class = blessed($self) || $self;
+
+    return ($self->loc( $REGISTRY{$class}{$lookup} ))
+        if defined $REGISTRY{$class}{$lookup};
+
+    my @types = map { s/^RT::// ? $self->loc($_) : $_ }
+      grep { defined and length }
+      split( /-/, $lookup )
+      or return;
+
+    state $LocStrings = [
+        "[_1] objects",            # loc
+        "[_1]'s [_2] objects",        # loc
+        "[_1]'s [_2]'s [_3] objects",   # loc
+    ];
+    return ( $self->loc( $LocStrings->[$#types], @types ) );
+}
+
+=head1 RecordClassFromLookupType
+
+Returns the type of Object referred to by ObjectCustomFields' ObjectId column.
+(The first part of the LookupType, e.g. the C<RT::Queue> of
+C<RT::Queue-RT::Ticket-RT::Transaction>)
+
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
+=cut
+
+sub RecordClassFromLookupType {
+    my $self = shift;
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /^([^-]+)/);
+    unless ( $class ) {
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                blessed($self) . " #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
+        return undef;
+    }
+    return $class;
+}
+
+=head1 ObjectTypeFromLookupType
+
+Returns the ObjectType for this record. (The last part of the LookupType,
+e.g. the C<RT::Transaction> of C<RT::Queue-RT::Ticket-RT::Transaction>)
+
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
+=cut
+
+sub ObjectTypeFromLookupType {
+    my $self = shift;
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /([^-]+)$/);
+    unless ( $class ) {
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                blessed($self) . " #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
+        return undef;
+    }
+    return $class;
+}
+
+sub CollectionClassFromLookupType {
+    my $self = shift;
+
+    my $record_class = shift || $self->RecordClassFromLookupType;
+    return undef unless $record_class;
+
+    my $collection_class;
+    if ( UNIVERSAL::can($record_class.'Collection', 'new') ) {
+        $collection_class = $record_class.'Collection';
+    } elsif ( UNIVERSAL::can($record_class.'es', 'new') ) {
+        $collection_class = $record_class.'es';
+    } elsif ( UNIVERSAL::can($record_class.'s', 'new') ) {
+        $collection_class = $record_class.'s';
+    } else {
+        $RT::Logger->error("Can not find a collection class for record class '$record_class'");
+        return undef;
+    }
+    return $collection_class;
+}
+
+=head1 IsOnlyGlobal
+
+Certain record types (users, groups) should only be added globally;
+codify that set here for reference.
+
+=cut
+
+sub IsOnlyGlobal {
+    my $self = shift;
+
+    return ($self->LookupType =~ /^RT::(?:Group|User)/io);
+
+}
+
+1;
+
diff --git a/share/html/Admin/CustomFields/Modify.html b/share/html/Admin/CustomFields/Modify.html
index 9067140ae4..f9866a8935 100644
--- a/share/html/Admin/CustomFields/Modify.html
+++ b/share/html/Admin/CustomFields/Modify.html
@@ -98,8 +98,9 @@
 % }
 
 <tr><td class="label"><&|/l&>Applies to</&></td>
-<td><& /Admin/Elements/SelectCustomFieldLookupType, 
-        Name => "LookupType", 
+<td><& /Admin/Elements/SelectLookupType,
+        Name => "LookupType",
+        Object => $CustomFieldObj,
         Default => $CustomFieldObj->LookupType || $LookupType, &>
 </td></tr>
 
diff --git a/share/html/Admin/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectCustomFieldLookupType
index eae8a3fd1c..3835880d08 100644
--- a/share/html/Admin/Elements/SelectCustomFieldLookupType
+++ b/share/html/Admin/Elements/SelectCustomFieldLookupType
@@ -45,16 +45,11 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<select name="<%$Name%>">
-%for my $option ($cf->LookupTypes) {
-<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $cf->FriendlyLookupType($option) %></option>
-%}
-</select>
-<%INIT>
-my $cf = RT::CustomField->new($session{'CurrentUser'});
+<& SelectLookupType, %ARGS, Class => 'RT::CustomField' &>
 
+<%INIT>
+RT->Deprecated(
+    Remove => '4.6',
+    Instead => 'SelectLookupType',
+);
 </%INIT>
-<%ARGS>
-$Default=> ''
-$Name => 'LookupType'
-</%ARGS>
diff --git a/share/html/Admin/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectLookupType
similarity index 85%
copy from share/html/Admin/Elements/SelectCustomFieldLookupType
copy to share/html/Admin/Elements/SelectLookupType
index eae8a3fd1c..1e6edb46ad 100644
--- a/share/html/Admin/Elements/SelectCustomFieldLookupType
+++ b/share/html/Admin/Elements/SelectLookupType
@@ -2,7 +2,7 @@
 %#
 %# COPYRIGHT:
 %#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
 %#                                          <sales at bestpractical.com>
 %#
 %# (Except where explicitly superseded by other copyright notices)
@@ -46,15 +46,16 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select name="<%$Name%>">
-%for my $option ($cf->LookupTypes) {
-<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $cf->FriendlyLookupType($option) %></option>
+%for my $option ($Object->LookupTypes) {
+<option value="<%$option%>"<%defined($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $Object->FriendlyLookupType($option) %></option>
 %}
 </select>
 <%INIT>
-my $cf = RT::CustomField->new($session{'CurrentUser'});
-
+$Object ||= $Class->new($session{'CurrentUser'});
 </%INIT>
 <%ARGS>
-$Default=> ''
+$Default => ''
 $Name => 'LookupType'
+$Object => undef
+$Class => ''
 </%ARGS>

commit 68af85feb7b3a23860c50383414faa3adde0c434
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:30:57 2017 +0000

    Allow RegisterLookupType to provide options besides just FriendlyName
    
    We are going to add new options including "CreateGroupPredicate",
    "AppliesToObjectPredicate" and also "Subgroups".

diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
index f15487d26c..96e25121b3 100644
--- a/lib/RT/Record/Role/LookupType.pm
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -85,10 +85,22 @@ with 'RT::Record::Role';
 
 =head1 PROVIDES
 
-=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
+=head2 RegisterLookupType LOOKUPTYPE OPTIONS
 
 Tell RT that a certain object accepts records of this role via a lookup
-type and provide a friendly name for them.
+type. I<OPTIONS> is a hash reference for which the following keys are
+used:
+
+=over 4
+
+=item FriendlyName
+
+The string to display in the UI to users for this lookup type
+
+=back
+
+For backwards compatibility, I<OPTIONS> may also be a string which is
+interpreted as specifying the I<FriendlyName>.
 
 Examples:
 
@@ -107,11 +119,15 @@ my %REGISTRY = ();
 sub RegisterLookupType {
     my $class = shift;
     my $path = shift;
-    my $friendly_name = shift;
+    my $options = shift;
 
     die "RegisterLookupType is a class method" if blessed($class);
 
-    $REGISTRY{$class}{$path} = $friendly_name;
+    $options = {
+        FriendlyName => $options,
+    } if !ref($options);
+
+    $REGISTRY{$class}{$path} = $options;
 }
 
 =head2 LookupTypes
@@ -126,6 +142,28 @@ sub LookupTypes {
     return sort keys %{ $REGISTRY{ $class } };
 }
 
+=head2 LookupTypeRegistration [PATH] [OPTION]
+
+Returns the arguments of calls to L</RegisterLookupType>. With no arguments, returns a hash of hashes,
+where the first-level key is the path (corresponding with L<RT::Record/CustomFieldLookupType>) and
+the second-level hash is the option names. If path and option are provided, it looks up in that
+nested hash structure to provide the desired information.
+
+=cut
+
+sub LookupTypeRegistration {
+    my $self = shift;
+    my $class = blessed($self) || $self;
+
+    my $path = shift
+        or return %{ $REGISTRY{$class}};
+
+    my $option = shift
+        or return %{ $REGISTRY{$class}{$path}};
+
+    return $REGISTRY{$class}{$path}{$option};
+}
+
 =head2 FriendlyLookupType
 
 Returns a localized description of the LookupType of this record
@@ -138,8 +176,9 @@ sub FriendlyLookupType {
 
     my $class = blessed($self) || $self;
 
-    return ($self->loc( $REGISTRY{$class}{$lookup} ))
-        if defined $REGISTRY{$class}{$lookup};
+    if (my $friendly = $self->LookupTypeRegistration($lookup, 'FriendlyName')) {
+        return $self->loc($friendly);
+    }
 
     my @types = map { s/^RT::// ? $self->loc($_) : $_ }
       grep { defined and length }

commit 49740f1d40b9d61fe27d31e9ad752bc8ec8197f1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:49:27 2017 +0000

    Add support for LookupType to custom roles
    
    This allows custom roles to be reused for any object class, not just tickets
    and queues.

diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index b3e677b827..bd80e709d8 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -519,6 +519,7 @@ CREATE TABLE CustomRoles (
         Description     VARCHAR2(255),
         MaxValues       NUMBER(11,0) DEFAULT 0 NOT NULL,
         EntryHint       VARCHAR2(255),
+        LookupType      VARCHAR2(255),
         Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
         Created         DATE,
         LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
diff --git a/etc/schema.Pg b/etc/schema.Pg
index aa4b437e0a..b0f4693b69 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -752,6 +752,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) NULL  ,
   MaxValues integer NOT NULL DEFAULT 0  ,
   EntryHint varchar(255) NULL  ,
+  LookupType varchar(255) NOT NULL  ,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created TIMESTAMP NULL  ,
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index f8e6ae9327..aae92cf6c9 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -548,6 +548,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) collate NOCASE NULL  ,
   MaxValues integer,
   EntryHint varchar(255) collate NOCASE NULL  ,
+  LookupType varchar(255) collate NOCASE NOT NULL,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created DATETIME NULL  ,
diff --git a/etc/schema.mysql b/etc/schema.mysql
index eefc145ca4..a29dd40daa 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -537,6 +537,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) NULL  ,
   MaxValues integer,
   EntryHint varchar(255) NULL  ,
+  LookupType varchar(255) CHARACTER SET ascii NOT NULL,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created DATETIME NULL  ,
diff --git a/etc/upgrade/4.4.5/schema.Oracle b/etc/upgrade/4.4.5/schema.Oracle
new file mode 100644
index 0000000000..300bf8d8b6
--- /dev/null
+++ b/etc/upgrade/4.4.5/schema.Oracle
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD LookupType VARCHAR2(255);
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/4.4.5/schema.Pg b/etc/upgrade/4.4.5/schema.Pg
new file mode 100644
index 0000000000..671d871f45
--- /dev/null
+++ b/etc/upgrade/4.4.5/schema.Pg
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType VARCHAR(255);
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/4.4.5/schema.SQLite b/etc/upgrade/4.4.5/schema.SQLite
new file mode 100644
index 0000000000..ec766a33cd
--- /dev/null
+++ b/etc/upgrade/4.4.5/schema.SQLite
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType VARCHAR(255) collate NOCASE;
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/4.4.5/schema.mysql b/etc/upgrade/4.4.5/schema.mysql
new file mode 100644
index 0000000000..850f200953
--- /dev/null
+++ b/etc/upgrade/4.4.5/schema.mysql
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType varchar(255) CHARACTER SET ascii;
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index 8ec00efdd3..92a0c6308a 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -55,6 +55,9 @@ use base 'RT::Record';
 use RT::CustomRoles;
 use RT::ObjectCustomRole;
 
+use Role::Basic 'with';
+with "RT::Record::Role::LookupType";
+
 =head1 NAME
 
 RT::CustomRole - user-defined role groups
@@ -79,6 +82,7 @@ Create takes a hash of values and creates a row in the database:
   varchar(255) 'Description'.
   int(11) 'MaxValues'.
   varchar(255) 'EntryHint'.
+  varchar(255) 'LookupType'.
   smallint(6) 'Disabled'.
 
 =cut
@@ -90,6 +94,7 @@ sub Create {
         Description => '',
         MaxValues   => 0,
         EntryHint   => '',
+        LookupType  => '',
         Disabled    => 0,
         @_,
     );
@@ -106,6 +111,9 @@ sub Create {
     $args{'Disabled'} ||= 0;
     $args{'MaxValues'} = int $args{'MaxValues'};
 
+    # backwards compatibility; used to be the only possibility
+    $args{'LookupType'} ||= 'RT::Queue-RT::Ticket';
+
     $RT::Handle->BeginTransaction;
 
     my ($ok, $msg) = $self->SUPER::Create(
@@ -113,6 +121,7 @@ sub Create {
         Description => $args{'Description'},
         MaxValues   => $args{'MaxValues'},
         EntryHint   => $args{'EntryHint'},
+        LookupType  => $args{'LookupType'},
         Disabled    => $args{'Disabled'},
     );
     unless ($ok) {
@@ -152,9 +161,9 @@ sub _RegisterAsRole {
     my $self = shift;
     my $id = $self->Id;
 
-    RT::Ticket->RegisterRole(
+    $self->ObjectTypeFromLookupType->RegisterRole(
         Name                 => $self->GroupType,
-        EquivClasses         => ['RT::Queue'],
+        EquivClasses         => [$self->RecordClassFromLookupType],
         Single               => $self->SingleValue,
         UserDefined          => 1,
 
@@ -171,17 +180,8 @@ sub _RegisterAsRole {
             my $role = RT::CustomRole->new(RT->SystemUser);
             $role->Load($id);
 
-            if ($object->isa('RT::Queue')) {
-                # In case queue level custom role groups got deleted
-                # somehow.  Allow to re-create them like default ones.
-                return $role->IsAdded($object->id);
-            }
-            elsif ($object->isa('RT::Ticket')) {
-                # see if the role has been applied to the ticket's queue
-                # need to walk around ACLs because of the common case of
-                # (e.g. Everyone) having the CreateTicket right but not
-                # ShowTicket
-                return $role->IsAdded($object->__Value('Queue'));
+            if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'CreateGroupPredicate')) {
+                return $predicate->($object, $role);
             }
 
             return 0;
@@ -205,16 +205,8 @@ sub _RegisterAsRole {
             my $role = RT::CustomRole->new(RT->SystemUser);
             $role->Load($id);
 
-            if ( $object->isa('RT::Ticket') || $object->isa('RT::Queue') ) {
-                return 0 unless $object->CurrentUserHasRight('SeeQueue');
-
-                # custom roles apply to queues, so canonicalize a ticket
-                # into its queue
-                if ( $object->isa('RT::Ticket') ) {
-                    $object = $object->QueueObj;
-                }
-
-                return $role->IsAdded( $object->Id );
+            if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'AppliesToObjectPredicate')) {
+                return $predicate->($object, $role);
             }
 
             return 0;
@@ -235,7 +227,7 @@ sub _RegisterAsRole {
 sub _UnregisterAsRole {
     my $self = shift;
 
-    RT::Ticket->UnregisterRole($self->GroupType);
+    $self->ObjectTypeFromLookupType->UnregisterRole($self->GroupType);
 }
 
 =head2 Load ID/NAME
@@ -375,7 +367,7 @@ sub NotAddedTo {
 
 =head2 AddToObject
 
-Adds (applies) this custom role to the provided queue (ObjectId).
+Adds (applies) this custom role to the provided object (ObjectId).
 
 Accepts a param hash of:
 
@@ -383,7 +375,7 @@ Accepts a param hash of:
 
 =item C<ObjectId>
 
-Queue name or id.
+Object id of the class corresponding with L</LookupType>.
 
 =item C<SortOrder>
 
@@ -400,26 +392,30 @@ 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;
+    my $class = $self->RecordClassFromLookupType;
+    my $object = $class->new( $self->CurrentUser );
+    $object->Load( $args{'ObjectId'} );
+    unless ($object->id) {
+        RT->Logger->warn("Unable to load $class '$args{'ObjectId'}' for custom role " . $self->Id);
+        return (0, $self->loc('Unable to load [_1]', $args{'ObjectId'}))
+    }
 
-    $args{'ObjectId'} = $queue->id;
+    $args{'ObjectId'} = $object->id;
 
     return ( 0, $self->loc('Permission Denied') )
-        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+        unless $object->CurrentUserHasRight('AdminCustomRoles');
+
     my $rec = RT::ObjectCustomRole->new( $self->CurrentUser );
     my ( $status, $add ) = $rec->Add( %args, CustomRole => $self );
     my $msg;
-    $msg = $self->loc("[_1] added to queue [_2]", $self->Name, $queue->Name) if $status;
+    $msg = $self->loc("[_1] added to queue [_2]", $self->Name, $object->Name) if $status;
 
     return ( $add, $msg );
 }
 
 =head2 RemoveFromObject
 
-Removes this custom role from the provided queue (ObjectId).
+Removes this custom role from the provided object (ObjectId).
 
 Accepts a param hash of:
 
@@ -427,7 +423,7 @@ Accepts a param hash of:
 
 =item C<ObjectId>
 
-Queue name or id.
+Object id of the class corresponding with L</LookupType>.
 
 =back
 
@@ -440,19 +436,25 @@ 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;
+    my $class = $self->RecordClassFromLookupType;
+    my $object = $class->new( $self->CurrentUser );
+    $object->Load( $args{'ObjectId'} );
+    unless ($object->id) {
+        RT->Logger->warn("Unable to load $class '$args{'ObjectId'}' for custom role " . $self->Id);
+        return (0, $self->loc('Unable to load [_1]', $args{'ObjectId'}))
+    }
+
+    $args{'ObjectId'} = $object->id;
 
     return ( 0, $self->loc('Permission Denied') )
-        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+        unless $object->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;
     my ( $status, $delete ) = $rec->Delete;
     my $msg;
-    $msg = $self->loc("[_1] removed from queue [_2]", $self->Name, $queue->Name) if $status;
+    $msg = $self->loc("[_1] removed from queue [_2]", $self->Name, $object->Name) if $status;
 
     return ( $delete, $msg );
 }
@@ -561,6 +563,39 @@ sub SetMaxValues {
     return ($ok, $msg);
 }
 
+=head2 LookupType
+
+Returns the current value of LookupType.
+(In the database, LookupType is stored as varchar(255).)
+
+=head2 SetLookupType VALUE
+
+
+Set LookupType to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, LookupType will be stored as a varchar(255).)
+
+=cut
+
+sub SetLookupType {
+    my $self = shift;
+    my $lookup = shift;
+    if ( $lookup ne $self->LookupType ) {
+        # Okay... We need to invalidate our existing relationships
+        RT::ObjectCustomRole->new($self->CurrentUser)->DeleteAll( CustomRole => $self );
+    }
+
+    $self->_UnregisterAsRole;
+
+    my ($ok, $msg) = $self->_Set(Field => 'LookupType', Value => $lookup);
+
+    # update EquivClasses declaration
+    $self->_RegisterAsRole;
+    RT->System->CustomRoleCacheNeedsUpdate(1);
+
+    return ($ok, $msg);
+}
+
 =head2 EntryHint
 
 Returns the current value of EntryHint.
@@ -615,62 +650,65 @@ Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
 
 =cut
 
-sub _SetGroupsDisabledForQueue {
+sub _SetGroupsDisabledForObject {
     my $self = shift;
     my $value = shift;
-    my $queue = shift;
+    my $object = shift;
 
-    # set disabled on the queue group
-    my $queue_group = RT::Group->new($self->CurrentUser);
-    $queue_group->LoadRoleGroup(
+    # set disabled on the object group
+    my $object_group = RT::Group->new($self->CurrentUser);
+    $object_group->LoadRoleGroup(
         Name   => $self->GroupType,
-        Object => $queue,
+        Object => $object,
     );
 
-    if (!$queue_group->Id) {
+    if (!$object_group->Id) {
         $RT::Handle->Rollback;
-        $RT::Logger->error("Couldn't find role group for " . $self->GroupType . " on queue " . $queue->Id);
+        $RT::Logger->error("Couldn't find role group for " . $self->GroupType . " on " . ref($object) . " #" . $object->Id);
         return(undef);
     }
 
-    my ($ok, $msg) = $queue_group->SetDisabled($value);
+    my ($ok, $msg) = $object_group->SetDisabled($value);
     unless ($ok) {
         $RT::Handle->Rollback;
         $RT::Logger->error("Couldn't SetDisabled($value) on role group: $msg");
         return(undef);
     }
 
-    # disable each existant ticket group
-    my $ticket_groups = RT::Groups->new($self->CurrentUser);
-
-    if ($value) {
-        $ticket_groups->LimitToEnabled;
-    }
-    else {
-        $ticket_groups->LimitToDeleted;
-    }
-
-    $ticket_groups->Limit(FIELD => 'Domain', OPERATOR => 'LIKE', VALUE => "RT::Ticket-Role", CASESENSITIVE => 0 );
-    $ticket_groups->Limit(FIELD => 'Name', OPERATOR => '=', VALUE => $self->GroupType, CASESENSITIVE => 0);
+    my $subgroup_config = $self->LookupTypeRegistration($self->LookupType, 'Subgroup');
+    if ($subgroup_config) {
+        # disable each existant ticket group
+        my $groups = RT::Groups->new($self->CurrentUser);
 
-    my $tickets = $ticket_groups->Join(
-        ALIAS1 => 'main',
-        FIELD1 => 'Instance',
-        TABLE2 => 'Tickets',
-        FIELD2 => 'Id',
-    );
-    $ticket_groups->Limit(
-        ALIAS => $tickets,
-        FIELD => 'Queue',
-        VALUE => $queue->Id,
-    );
+        if ($value) {
+            $groups->LimitToEnabled;
+        }
+        else {
+            $groups->LimitToDeleted;
+        }
 
-    while (my $ticket_group = $ticket_groups->Next) {
-        my ($ok, $msg) = $ticket_group->SetDisabled($value);
-        unless ($ok) {
-            $RT::Handle->Rollback;
-            $RT::Logger->error("Couldn't SetDisabled($value) ticket role group: $msg");
-            return(undef);
+        $groups->Limit(FIELD => 'Domain', OPERATOR => 'LIKE', VALUE => $subgroup_config->{Domain}, CASESENSITIVE => 0 );
+        $groups->Limit(FIELD => 'Name', OPERATOR => '=', VALUE => $self->GroupType, CASESENSITIVE => 0);
+
+        my $objects = $groups->Join(
+            ALIAS1 => 'main',
+            FIELD1 => 'Instance',
+            TABLE2 => $subgroup_config->{Table},
+            FIELD2 => 'Id',
+        );
+        $groups->Limit(
+            ALIAS => $objects,
+            FIELD => $subgroup_config->{Parent},
+            VALUE => $object->Id,
+        );
+
+        while (my $group = $groups->Next) {
+            my ($ok, $msg) = $group->SetDisabled($value);
+            unless ($ok) {
+                $RT::Handle->Rollback;
+                $RT::Logger->error("Couldn't SetDisabled($value) role group: $msg");
+                return(undef);
+            }
         }
     }
 }
@@ -710,6 +748,8 @@ sub _CoreAccessible {
         {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 => ''},
+        LookupType =>
+        {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 =>
diff --git a/lib/RT/CustomRoles.pm b/lib/RT/CustomRoles.pm
index cca57926c0..9d92e0e904 100644
--- a/lib/RT/CustomRoles.pm
+++ b/lib/RT/CustomRoles.pm
@@ -156,6 +156,19 @@ sub LimitToMultipleValue {
     );
 }
 
+=head2 LimitToLookupType
+
+Takes LookupType and limits collection.
+
+=cut
+
+sub LimitToLookupType  {
+    my $self = shift;
+    my $lookup = shift;
+
+    $self->Limit( FIELD => 'LookupType', VALUE => "$lookup" );
+}
+
 =head2 ApplySortOrder
 
 Sort custom roles according to the order provided by the object custom roles.
diff --git a/lib/RT/ObjectCustomRole.pm b/lib/RT/ObjectCustomRole.pm
index d1bba54641..770ec487b6 100644
--- a/lib/RT/ObjectCustomRole.pm
+++ b/lib/RT/ObjectCustomRole.pm
@@ -57,11 +57,11 @@ use RT::ObjectCustomRoles;
 
 =head1 NAME
 
-RT::ObjectCustomRole - record representing addition of a custom role to a queue
+RT::ObjectCustomRole - record representing addition of a custom role to an object
 
 =head1 DESCRIPTION
 
-This record is created if you want to add a custom role to a queue.
+This record is created if you want to add a custom role to an object.
 
 Inherits methods from L<RT::Record::AddAndSort>.
 
@@ -79,12 +79,16 @@ 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.
+Returns class name of collection of records this custom role can be added to
+by consulting the custom role's C<LookupType>.
 
 =cut
 
-sub ObjectCollectionClass {'RT::Queues'}
+sub ObjectCollectionClass {
+    my $self = shift;
+    my %args = (@_);
+    return $args{'CustomRole'}->CollectionClassFromLookupType;
+}
 
 =head2 CustomRoleObj
 
@@ -100,22 +104,30 @@ sub CustomRoleObj {
     return $obj;
 }
 
-=head2 QueueObj
+=head2 Object
 
-Returns the L<RT::Queue> object which this ObjectCustomRole is added to
+Returns the object which this ObjectCustomRole is added to
 
 =cut
 
+sub Object {
+    my $self = shift;
+    my $role = $self->CustomRoleObj;
+    my $class = $role->RecordClassFromLookupType;
+    my $object = $class->new($self->CurrentUser);
+    $object->Load($self->ObjectId);
+    return $object;
+}
+
 sub QueueObj {
     my $self = shift;
-    my $queue = RT::Queue->new($self->CurrentUser);
-    $queue->Load($self->ObjectId);
-    return $queue;
+    RT->Deprecated( Instead => "Object", Remove => '4.6' );
+    return $self->Object(@_);
 }
 
 =head2 Add
 
-Adds the custom role to the queue and creates (or re-enables) that queue's role
+Adds the custom role to the object and creates (or re-enables) that object's role
 group.
 
 =cut
@@ -132,15 +144,15 @@ sub Add {
         return(undef);
     }
 
-    my $queue = $self->QueueObj;
+    my $object = $self->Object;
     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)
+    # add a role to an object, remove it, then add it back in)
     my $existing = RT::Group->new($self->CurrentUser);
     $existing->LoadRoleGroup(
         Name   => $role->GroupType,
-        Object => $queue,
+        Object => $object,
     );
 
     if ($existing->Id) {
@@ -150,7 +162,7 @@ sub Add {
         my $group = RT::Group->new($self->CurrentUser);
         my ($ok, $msg) = $group->CreateRoleGroup(
             Name   => $role->GroupType,
-            Object => $queue,
+            Object => $object,
         );
 
         unless ($ok) {
@@ -168,7 +180,7 @@ sub Add {
 
 =head2 Delete
 
-Removes the custom role from the queue and disables that queue's role group.
+Removes the custom role from the object and disables that object's role group.
 
 =cut
 
diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 8450b6801c..bf0b158a22 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -481,6 +481,7 @@ sub CustomRoles {
     my $roles = RT::CustomRoles->new( $self->CurrentUser );
     if ( $self->CurrentUserHasRight('SeeQueue') ) {
         $roles->LimitToObjectId( $self->Id );
+        $roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
         $roles->ApplySortOrder;
     }
     else {
@@ -1101,6 +1102,7 @@ sub FindDependencies {
     # Object Custom Roles
     $objs = RT::ObjectCustomRoles->new( $self->CurrentUser );
     $objs->LimitToObjectId($self->Id);
+    $objs->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
     $deps->Add( in => $objs );
 }
 
diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index d1b83e42d8..add1c374d8 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -113,6 +113,50 @@ for my $role (sort keys %ROLES) {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Tickets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Queue')) {
+                # In case queue level custom role groups got deleted
+                # somehow.  Allow to re-create them like default ones.
+                return $role->IsAdded($object->id);
+            }
+            elsif ($object->isa('RT::Ticket')) {
+                # see if the role has been applied to the ticket's queue
+                # need to walk around ACLs because of the common case of
+                # (e.g. Everyone) having the CreateTicket right but not
+                # ShowTicket
+                return $role->IsAdded($object->__Value('Queue'));
+            }
+
+            return 0;
+        },
+        AppliesToObjectPredicate => sub {
+            my ($object, $role) = @_;
+            return 0 unless $object->CurrentUserHasRight('SeeQueue');
+
+            # 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;
+        },
+        Subgroup => {
+            Domain => 'RT::Ticket-Role',
+            Table  => 'Tickets',
+            Parent => 'Queue',
+        },
+    }
+);
+
 our %MERGE_CACHE = (
     effective => {},
     merged => {},
diff --git a/share/html/Admin/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html
index 8802d63e52..6078e1b47e 100644
--- a/share/html/Admin/CustomRoles/Modify.html
+++ b/share/html/Admin/CustomRoles/Modify.html
@@ -63,6 +63,13 @@
 <td colspan="3"><input name="Description" value="<% $Create ? "" : $RoleObj->Description || $Description || '' %>" size="60" /></td>
 </tr>
 
+<tr><td align="right"><&|/l&>Applies to</&></td>
+<td><& /Admin/Elements/SelectLookupType,
+    Name    => "LookupType",
+    Object  => $RoleObj,
+    Default => $RoleObj->LookupType || $LookupType,
+&></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>
@@ -127,7 +134,7 @@ unless ($Create) {
 
 if ( $RoleObj->Id ) {
     $title = loc('Configuration for role [_1]', $RoleObj->Name );
-    my @attribs = qw(Description Name EntryHint Disabled);
+    my @attribs = qw(Description Name EntryHint LookupType Disabled);
 
     # we just created the role
     if (!$id || $id eq 'new') {
@@ -185,4 +192,5 @@ $SetEnabled => undef
 $SetMultiple => undef
 $Multiple => undef
 $Enabled => undef
+$LookupType => RT::Ticket->CustomFieldLookupType
 </%ARGS>
diff --git a/share/html/Admin/CustomRoles/Objects.html b/share/html/Admin/CustomRoles/Objects.html
index 611cc2149c..5661799a39 100644
--- a/share/html/Admin/CustomRoles/Objects.html
+++ b/share/html/Admin/CustomRoles/Objects.html
@@ -56,8 +56,8 @@
 <h2><&|/l&>Selected objects</&></h2>
 
 <& /Elements/CollectionList,
-    OrderBy => 'id',
-    Order => 'ASC',
+    OrderBy => $class->isa('RT::Queue') ? ['SortOrder', 'Name'] : 'id',
+    Order => $class->isa('RT::Queue') ? ['ASC', 'ASC'] : 'ASC',
     %ARGS,
     Collection => $added,
     Rows => 0,
@@ -74,8 +74,8 @@
 <h2><&|/l&>Unselected objects</&></h2>
 
 <& /Elements/CollectionList,
-    OrderBy => 'Name',
-    Order   => 'ASC',
+    OrderBy => $class->isa('RT::Queue') ? ['SortOrder', 'Name'] : 'id',
+    Order => $class->isa('RT::Queue') ? ['ASC', 'ASC'] : 'ASC',
     %ARGS,
     Collection    => $not_added,
     Rows          => $rows,
@@ -102,6 +102,8 @@ my $role = RT::CustomRole->new( $session{'CurrentUser'} );
 $role->Load($id) or Abort(loc("Could not load custom role #[_1]", $id));
 $id = $role->id;
 
+my $class = $role->RecordClassFromLookupType;
+
 if ($role->Disabled) {
     Abort(loc("Cannot modify objects of disabled custom role #[_1]", $id));
 }
@@ -132,8 +134,12 @@ if ( $Update ) {
 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 $collection_class = ref($added);
+$collection_class =~ s/^RT:://;
+
+my $format = RT->Config->Get('AdminSearchResultFormat')->{$collection_class}
+    || '__id__,__Name__';
+my $rows = RT->Config->Get('AdminSearchResultRows')->{$collection_class} || 50;
 
 my $title = loc('Modify associated objects for [_1]', $role->Name);
 

commit d32c3c03406fa58d3b4ca4b497cf24c8c5f65f9c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Apr 19 21:04:13 2017 +0000

    Add custom roles to assets

diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
index fbd815e494..f9d629ea04 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -101,6 +101,48 @@ for my $role ('Owner', 'HeldBy', 'Contact') {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Assets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Catalog')) {
+                # In case catalog level custom role groups got deleted
+                # somehow.  Allow to re-create them like default ones.
+                return $role->IsAdded($object->id);
+            }
+            elsif ($object->isa('RT::Asset')) {
+                # see if the role has been applied to the asset's catalog
+                # need to walk around ACLs
+                return $role->IsAdded($object->__Value('Catalog'));
+            }
+
+            return 0;
+        },
+        AppliesToObjectPredicate => sub {
+            my ($object, $role) = @_;
+            return 0 unless $object->CurrentUserHasRight('ShowCatalog');
+
+            # custom roles apply to catalogs, so canonicalize an asset
+            # into its catalog
+            if ($object->isa('RT::Asset')) {
+                $object = $object->CatalogObj;
+            }
+
+            if ($object->isa('RT::Catalog')) {
+                return $role->IsAdded($object->Id);
+            }
+
+            return 0;
+        },
+        Subgroup => {
+            Domain => 'RT::Asset-Role',
+            Table  => 'Assets',
+            Parent => 'Catalog',
+        },
+    }
+);
+
 =head1 DESCRIPTION
 
 An Asset is a small record object upon which zero to many custom fields are
@@ -255,7 +297,7 @@ sub Create {
     }
 
     my $roles = {};
-    my @errors = $self->_ResolveRoles( $roles, %args );
+    my @errors = $catalog->_ResolveRoles( $roles, %args );
     return (0, @errors) if @errors;
 
     RT->DatabaseHandle->BeginTransaction();
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 10a4a4192a..378f521819 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4266,11 +4266,16 @@ sub ProcessAssetRoleMembers {
         elsif ($arg =~ /^SetRoleMember-(.+)$/) {
             my $role = $1;
             my $group = $object->RoleGroup($role);
+            if ( !$group->id ) {
+                $group = $object->_CreateRoleGroup($role);
+            }
             next unless $group->id and $group->SingleMemberRoleGroup;
-            next if $ARGS{$arg} eq $group->UserMembersObj->First->Name;
+            my $original_user = $group->UserMembersObj->First || RT->Nobody;
+            $ARGS{$arg} ||= 'Nobody';
+            next if $ARGS{$arg} eq $original_user->Name;
             my ($ok, $msg) = $object->AddRoleMember(
                 Type => $role,
-                User => $ARGS{$arg} || 'Nobody',
+                User => $ARGS{$arg},
             );
             push @results, $msg;
         }
diff --git a/lib/RT/Principal.pm b/lib/RT/Principal.pm
index 821d81e79d..16b209647b 100644
--- a/lib/RT/Principal.pm
+++ b/lib/RT/Principal.pm
@@ -452,7 +452,7 @@ sub HasRights {
             if ( $custom_role->id && !$custom_role->Disabled ) {
                 my $added;
                 for my $object ( @{ $args{'EquivObjects'} } ) {
-                    next unless $object->isa('RT::Queue');
+                    next unless $object->isa('RT::Queue') || $object->isa('RT::Catalog');
                     if ( $custom_role->IsAdded( $object->id ) ) {
                         $added = 1;
                         last;
@@ -689,7 +689,7 @@ sub RolesWithRight {
             if ( $custom_role->id && !$custom_role->Disabled ) {
                 my $added;
                 for my $object ( @{ $args{'EquivObjects'} } ) {
-                    next unless $object->isa('RT::Queue');
+                    next unless $object->isa('RT::Queue') || $object->isa('RT::Catalog');
                     if ( $custom_role->IsAdded( $object->id ) ) {
                         $added = 1;
                         last;
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index bff2c46acd..d205e7e4b4 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -296,9 +296,9 @@ sub Roles {
              map { [ $_, $self->_ROLES->{$_} ] }
             keys %{ $self->_ROLES };
 
-    # Cache at ticket/queue object level mainly to reduce calls of
-    # custom role's AppliesToObjectPredicate for performance.
-    if ( ref($self) =~ /RT::(?:Ticket|Queue)/ ) {
+    # Cache at object level mainly to reduce calls of custom role's
+    # AppliesToObjectPredicate for performance.
+    if ( ref($self) =~ /RT::(?:Ticket|Queue|Asset|Catalog)/ ) {
         $self->{_Roles}{$key} = \@roles;
     }
     return @roles;
diff --git a/share/html/Asset/Create.html b/share/html/Asset/Create.html
index 39ae7e6c83..cf63207a3f 100644
--- a/share/html/Asset/Create.html
+++ b/share/html/Asset/Create.html
@@ -72,7 +72,7 @@
   </&>
 
   <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people", title_class => "inverse" &>
-    <& Elements/EditPeople, %ARGS, AssetObj => $asset &>
+    <& Elements/EditPeople, %ARGS, AssetObj => $asset, CatalogObj => $catalog &>
   </&>
   </td><td>
   <&| /Widgets/TitleBox, title => loc("Links"), class => "asset-links", title_class => "inverse" &>
@@ -154,7 +154,7 @@ if ($id eq "new") {
             ProcessLinksForCreate( ARGSRef => \%ARGS ),
             map {
                 $_ => $ARGS{$_}
-            } $asset->Roles,
+            } $catalog->Roles,
         );
 
         # Handle basic fields
diff --git a/share/html/Asset/Elements/AssetSearchPeople b/share/html/Asset/Elements/AssetSearchPeople
index 6355f8aef1..c83bdccc11 100644
--- a/share/html/Asset/Elements/AssetSearchPeople
+++ b/share/html/Asset/Elements/AssetSearchPeople
@@ -47,9 +47,9 @@
 %# END BPS TAGGED BLOCK }}}
 <&| /Widgets/TitleBox, class => "asset-search-people", title => loc('People') &>
 <table>
-% for my $role (RT::Asset->Roles) {
+% for my $role ($CatalogObj->Roles) {
 <tr class="asset-role-<% CSSClass($role) %>">
-  <td class="label"><label for="Role.<% $role %>"><% loc($role) %></td>
+  <td class="label"><label for="Role.<% $role %>"><% RT::Asset->LabelForRole($role) %></td>
   <td class="value">
       <input type="text" id="Role.<% $role %>" name="Role.<% $role %>"
              data-autocomplete="Users" value="<% $ARGS{"Role.$role"} || '' %>" />
diff --git a/share/html/Asset/Elements/EditCatalogPeople b/share/html/Asset/Elements/EditCatalogPeople
index 4438c5b24f..24adcb559c 100644
--- a/share/html/Asset/Elements/EditCatalogPeople
+++ b/share/html/Asset/Elements/EditCatalogPeople
@@ -52,8 +52,8 @@ $Object
 </%init>
 % for my $role ($Object->Roles( ACLOnly => 0 )) {
 <div class="role-<% CSSClass($role) %> role">
-  <h3><% loc($role) %></h3>
-  <& EditRoleMembers, Group => $Object->RoleGroup($role) &>
+  <h3><% $Object->LabelForRole($role) %></h3>
+  <& EditRoleMembers, Object => $Object, Role => $role &>
 </div>
 % }
 <em><&|/l&>(Check box to delete)</&></em>
diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople
index 89ace8e207..9420683032 100644
--- a/share/html/Asset/Elements/EditPeople
+++ b/share/html/Asset/Elements/EditPeople
@@ -46,19 +46,32 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <table border="0" cellpadding="0" cellspacing="0">
-% for my $role ( $AssetObj->Roles ) {
+% for my $role ( ($AssetObj->Id ? $AssetObj->Id : $CatalogObj)->Roles ) {
 <tr class="asset-people-<% CSSClass($role) %>">
 <td class="label">
-<% loc($role) %>:
+<% ($AssetObj->Id ? $AssetObj->Id : $CatalogObj)->LabelForRole($role) %>:
 </td>
 <td class="value" colspan="5">
-<& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, ($AssetObj->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &>
+<& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, (($AssetObj->Id ? $AssetObj->Id : $CatalogObj)->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &>
 </td>
 </tr>
+
+% my $custom_role = $AssetObj->CustomRoleObj($role);
+% if ($custom_role && $custom_role->EntryHint) {
+<tr>
+  <td class="label"> </td>
+  <td class="comment" colspan="5">
+    <i><font size="-2">
+      <% $custom_role->EntryHint %>
+    </font></i>
+  </td>
+</tr>
+% }
 % }
 
 </table>
 
 <%args>
 $AssetObj
+$CatalogObj
 </%args>
diff --git a/share/html/Asset/Elements/EditRoleMembers b/share/html/Asset/Elements/EditRoleMembers
index dcbb5fa8f5..cfb78f2e01 100644
--- a/share/html/Asset/Elements/EditRoleMembers
+++ b/share/html/Asset/Elements/EditRoleMembers
@@ -46,16 +46,19 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%args>
-$Group       => undef
+$Object
+$Role
 $Recursively => 0
 </%args>
 <%init>
+my $Group = $Object->RoleGroup($Role);
 my $field_name = "RemoveRoleMember-" . $Group->Name;
 </%init>
 <ul class="role-members">
 % my $Users = $Group->UserMembersObj( Recursively => $Recursively );
-% if ($Group->SingleMemberRoleGroup) {
-<input type="text" value="<% $Users->First->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" /><br />
+% if ($Object->Role($Role)->{Single}) {
+% my $user = $Users->First || RT->Nobody;
+<input type="text" value="<% $user->Name %>" name="SetRoleMember-<% $Role %>" id="SetRoleMember-<% $Role %>" data-autocomplete="Users" data-autocomplete-return="Name" /><br />
 % } else {
 % while ( my $user = $Users->Next ) {
 <li>
diff --git a/share/html/Asset/Elements/SelectRoleType b/share/html/Asset/Elements/SelectRoleType
index 6abbc2d792..0dcf70fece 100644
--- a/share/html/Asset/Elements/SelectRoleType
+++ b/share/html/Asset/Elements/SelectRoleType
@@ -55,6 +55,6 @@ $AllowNull  => 0
   <option value=""></option>
 % }
 % for my $role ($Object->Roles( ACLOnly => 0, Single => 0 )) {
-  <option value="<% $role %>"><% loc($role) %></option>
+  <option value="<% $role %>"><% $Object->LabelForRole($role) %></option>
 % }
 </select>
diff --git a/share/html/Asset/Elements/ShowPeople b/share/html/Asset/Elements/ShowPeople
index 1fc9ef6a32..2dd9193b15 100644
--- a/share/html/Asset/Elements/ShowPeople
+++ b/share/html/Asset/Elements/ShowPeople
@@ -53,11 +53,11 @@ my $CatalogObj = $AssetObj->CatalogObj;
 </%init>
 <table>
 % for my $role ($AssetObj->Roles) {
-<tr><td class="label"><% loc($role) %>:
+<tr><td class="label"><% $AssetObj->LabelForRole($role) %>:
 % if ($AssetObj->Role($role)->{Single}) {
 %      my $users = $AssetObj->RoleGroup($role)->UserMembersObj(Recursively => 0);
 %      $users->FindAllRows;
-%      my $user = $users->Next;
+%      my $user = $users->Next || RT->Nobody;
 <& /Elements/ShowUser, User => $user, Link => 1 &></td></tr>
 %      next if $user->id == RT->Nobody->id;
 <tr><td>

commit f955f75884a678c47183aa9355503aeabb5a1d35
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 15:49:29 2017 +0000

    Add API and web tests for interacting with custom roles on assets

diff --git a/t/customroles/assets.t b/t/customroles/assets.t
new file mode 100644
index 0000000000..314041f908
--- /dev/null
+++ b/t/customroles/assets.t
@@ -0,0 +1,330 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+my $general = create_catalog( Name => 'General' );
+my $inbox = create_catalog( Name => 'Inbox' );
+my $specs = create_catalog( Name => 'Specs' );
+my $development = create_catalog( Name => 'Development' );
+
+my $engineer = RT::CustomRole->new(RT->SystemUser);
+my $sales = RT::CustomRole->new(RT->SystemUser);
+my $unapplied = RT::CustomRole->new(RT->SystemUser);
+
+my $linus = RT::Test->load_or_create_user( EmailAddress => 'linus at example.com' );
+my $blake = RT::Test->load_or_create_user( EmailAddress => 'blake at example.com' );
+my $williamson = RT::Test->load_or_create_user( EmailAddress => 'williamson at example.com' );
+my $moss = RT::Test->load_or_create_user( EmailAddress => 'moss at example.com' );
+my $ricky = RT::Test->load_or_create_user( EmailAddress => 'ricky.roma at example.com' );
+
+my $team = RT::Test->load_or_create_group(
+    'Team',
+    Members => [$blake, $williamson, $moss, $ricky],
+);
+
+sub txn_messages_like {
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+
+    my $a = shift;
+    my $re = shift;
+
+    my $txns = $a->Transactions;
+    $txns->Limit(FIELD => 'Type', VALUE => 'SetWatcher');
+    $txns->Limit(FIELD => 'Type', VALUE => 'AddWatcher');
+    $txns->Limit(FIELD => 'Type', VALUE => 'DelWatcher');
+
+    is($txns->Count, scalar(@$re), 'expected number of transactions');
+
+    while (my $txn = $txns->Next) {
+        like($txn->BriefDescription, (shift(@$re) || qr/(?!)/));
+    }
+}
+
+diag 'setup' if $ENV{'TEST_VERBOSE'};
+{
+    ok( RT::Test->add_rights( { Principal => 'Privileged', Right => [ qw(CreateAsset ShowAsset ModifyAsset ShowCatalog) ] } ));
+
+    my ($ok, $msg) = $engineer->Create(
+        Name       => 'Engineer-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 1,
+    );
+    ok($ok, "created Engineer role: $msg");
+
+    ($ok, $msg) = $sales->Create(
+        Name       => 'Sales-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 0,
+    );
+    ok($ok, "created Sales role: $msg");
+
+    ($ok, $msg) = $unapplied->Create(
+        Name       => 'Unapplied-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 0,
+    );
+    ok($ok, "created Unapplied role: $msg");
+
+    ($ok, $msg) = $sales->AddToObject($inbox->id);
+    ok($ok, "added Sales to Inbox: $msg");
+
+    ($ok, $msg) = $sales->AddToObject($specs->id);
+    ok($ok, "added Sales to Specs: $msg");
+
+    ($ok, $msg) = $engineer->AddToObject($specs->id);
+    ok($ok, "added Engineer to Specs: $msg");
+
+    ($ok, $msg) = $engineer->AddToObject($development->id);
+    ok($ok, "added Engineer to Development: $msg");
+}
+
+diag 'create assets in General (no custom roles)' if $ENV{'TEST_VERBOSE'};
+{
+    my $general1 = create_asset(
+        Catalog   => 'General',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($general1->Owner->id, $williamson->id, 'owner is correct');
+    is($general1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($general1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($general1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $general2 = create_asset(
+        Catalog   => 'General',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($general2->Owner->id, $linus->id, 'owner is correct');
+    is($general2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($general2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($general2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $general3 = create_asset(
+        Catalog              => 'General',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($general3->Owner->id, $ricky->id, 'owner is correct');
+    is($general3->RoleAddresses('Contact'), '', 'no contacts');
+    is($general3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($general3->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general3->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+}
+
+diag 'create assets in Inbox (sales role)' if $ENV{'TEST_VERBOSE'};
+{
+    my $inbox1 = create_asset(
+        Catalog   => 'Inbox',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($inbox1->Owner->id, $williamson->id, 'owner is correct');
+    is($inbox1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($inbox1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $inbox2 = create_asset(
+        Catalog   => 'Inbox',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($inbox2->Owner->id, $linus->id, 'owner is correct');
+    is($inbox2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($inbox2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($inbox2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $inbox3 = create_asset(
+        Catalog              => 'Inbox',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($inbox3->Owner->id, $ricky->id, 'owner is correct');
+    is($inbox3->RoleAddresses('Contact'), '', 'no contacts');
+    is($inbox3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox3->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox3->RoleAddresses($sales->GroupType), $blake->EmailAddress, 'got sales');
+
+    my $inbox4 = create_asset(
+        Catalog              => 'Inbox',
+        Name                 => 'more',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress, $williamson->EmailAddress],
+    );
+    is($inbox4->Owner->id, $ricky->id, 'owner is correct');
+    is($inbox4->RoleAddresses('Contact'), '', 'no contacts');
+    is($inbox4->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox4->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox4->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $williamson->EmailAddress), 'got sales');
+}
+
+diag 'create assets in Specs (both roles)' if $ENV{'TEST_VERBOSE'};
+{
+    my $specs1 = create_asset(
+        Catalog   => 'Specs',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($specs1->Owner->id, $williamson->id, 'owner is correct');
+    is($specs1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($specs1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($specs1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $specs2 = create_asset(
+        Catalog   => 'Specs',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($specs2->Owner->id, $linus->id, 'owner is correct');
+    is($specs2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($specs2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($specs2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($specs2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $specs3 = create_asset(
+        Catalog              => 'Specs',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($specs3->Owner->id, $ricky->id, 'owner is correct');
+    is($specs3->RoleAddresses('Contact'), '', 'no contacts');
+    is($specs3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs3->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'got engineer');
+    is($specs3->RoleAddresses($sales->GroupType), $blake->EmailAddress, 'got sales');
+
+    my $specs4 = create_asset(
+        Catalog              => 'Specs',
+        Name                 => 'more',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress, $williamson->EmailAddress],
+    );
+    is($specs4->Owner->id, $ricky->id, 'owner is correct');
+    is($specs4->RoleAddresses('Contact'), '', 'no contacts');
+    is($specs4->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs4->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'got engineer');
+    is($specs4->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $williamson->EmailAddress), 'got sales');
+}
+
+diag 'update asset in Specs' if $ENV{'TEST_VERBOSE'};
+{
+    my $a = create_asset(
+        Catalog => 'Specs',
+        Name    => 'updates',
+    );
+
+    is($a->Owner->id, RT->Nobody->id, 'owner nobody');
+    is($a->RoleAddresses('Contact'), '', 'no contacts');
+    is($a->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($a->RoleAddresses($engineer->GroupType), '', 'no engineer');
+    is($a->RoleAddresses($sales->GroupType), '', 'no sales');
+    is($a->RoleAddresses($unapplied->GroupType), '', 'no unapplied');
+
+    my ($ok, $msg) = $a->AddRoleMember(Type => 'Owner', Principal => $linus->PrincipalObj);
+    ok($ok, "set owner: $msg");
+    is($a->Owner->id, $linus->id, 'owner linus');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => 'Contact', Principal => $ricky->PrincipalObj);
+    ok($ok, "add contact: $msg");
+    is($a->RoleAddresses('Contact'), $ricky->EmailAddress, 'contact ricky');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => 'HeldBy', Principal => $blake->PrincipalObj);
+    ok($ok, "add heldby: $msg");
+    is($a->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby blake');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $ricky->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), $ricky->EmailAddress, 'sales ricky');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $moss->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $ricky->EmailAddress, $moss->EmailAddress), 'sales ricky and moss');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => RT->Nobody->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $ricky->EmailAddress, $moss->EmailAddress), 'sales ricky and moss');
+
+    ($ok, $msg) = $a->DeleteRoleMember(Type => $sales->GroupType, PrincipalId => $moss->PrincipalId);
+    ok($ok, "remove sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), $ricky->EmailAddress, 'sales ricky');
+
+    ($ok, $msg) = $a->DeleteRoleMember(Type => $sales->GroupType, PrincipalId => $ricky->PrincipalId);
+    ok($ok, "remove sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), '', 'sales empty');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $linus->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'engineer linus');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $blake->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), $blake->EmailAddress, 'engineer blake (single-member role so linus gets displaced)');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => RT->Nobody->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), '', 'engineer nobody (single-member role so blake gets displaced)');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $unapplied->GroupType, Principal => $linus->PrincipalObj);
+    ok(!$ok, "did not add unapplied role member: $msg");
+    like($msg, qr/That role is invalid for this object/);
+    is($a->RoleAddresses($unapplied->GroupType), '', 'no unapplied members');
+
+    txn_messages_like($a, [
+        qr/Owner set to linus\@example\.com/,
+        qr/Contact ricky\.roma\@example\.com added/,
+        qr/Held By blake\@example\.com added/,
+        qr/Sales-$$ ricky\.roma\@example\.com added/,
+        qr/Sales-$$ moss\@example\.com added/,
+        qr/Sales-$$ Nobody in particular added/,
+        qr/Sales-$$ moss\@example\.com deleted/,
+        qr/Sales-$$ ricky\.roma\@example\.com deleted/,
+        qr/Engineer-$$ set to linus\@example\.com/,
+        qr/Engineer-$$ set to blake\@example\.com/,
+        qr/Engineer-$$ set to Nobody in particular/,
+    ]);
+}
+
+diag 'groups can be role members' if $ENV{'TEST_VERBOSE'};
+{
+    my $a = create_asset(
+        Catalog => 'Specs',
+        Name    => 'groups',
+    );
+
+    my ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $team->PrincipalObj);
+    ok($ok, "add team: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $ricky->EmailAddress, $moss->EmailAddress, $williamson->EmailAddress), 'sales is all the team members');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $team->PrincipalObj);
+    ok(!$ok, "could not add team: $msg");
+    like($msg, qr/cannot be a group/);
+    is($a->RoleAddresses($engineer->GroupType), '', 'engineer is still nobody');
+
+    txn_messages_like($a, [
+        qr/Sales-$$ group Team added/,
+    ]);
+}
+
+done_testing;
diff --git a/t/customroles/web-assets.t b/t/customroles/web-assets.t
new file mode 100644
index 0000000000..03118dc880
--- /dev/null
+++ b/t/customroles/web-assets.t
@@ -0,0 +1,244 @@
+use strict;
+use warnings;
+use RT::Test::Assets tests => undef;
+my ($baseurl, $m) = RT::Test::Assets->started_ok;
+ok $m->login, "Logged in agent";
+
+
+my $catalog = create_catalog( Name => "Software" );
+ok $catalog->id, "Created Catalog";
+
+my $owner = RT::Test->load_or_create_user(Name => 'owner', EmailAddress => 'owner at example.com');
+my $licensee = RT::Test->load_or_create_user(Name => 'licensee at example.com', EmailAddress => 'licensee at example.com', Password => 'password');
+
+my $role;
+my ($asset, $asset2, $asset3);
+
+diag "Create custom role and apply it to General assets";
+{
+    $m->follow_link_ok({ id => "admin-custom-roles-create" }, "Custom Role create link");
+    $m->submit_form_ok({
+        with_fields => {
+            Name        => 'Licensee',
+            Description => 'The person who licensed the software',
+            LookupType  => RT::Asset->CustomFieldLookupType,
+            EntryHint   => 'Make sure user has real name set',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Custom role created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+    ok($id, 'Got role id');
+
+    $role = RT::CustomRole->new(RT->SystemUser);
+    $role->Load($id);
+    is $role->id, $id, "id matches";
+    is $role->Name, "Licensee", "Name matches";
+    is $role->Description, "The person who licensed the software", "Description matches";
+    is $role->LookupType, RT::Asset->CustomFieldLookupType, "LookupType matches";
+    is $role->EntryHint, "Make sure user has real name set", "EntryHint matches";
+
+    ok(!$role->IsAdded($catalog->Id), 'not added to catalog yet');
+
+    $m->follow_link_ok({ id => "page-applies-to" }, "Applies to link");
+    $m->submit_form_ok({
+        with_fields => {
+            ("AddRole-" . $id) => $catalog->Id,
+        },
+        button => 'Update',
+    }, "submitted applies to form");
+    $m->text_contains('Licensee added to queue Software', "Found update message");
+
+    # refresh cache
+    RT::CustomRoles->RegisterRoles;
+
+    ok($role->IsAdded($catalog->Id), 'added to catalog now');
+    is_deeply([sort $catalog->Roles], [sort 'Contact', 'HeldBy', 'Owner', $role->GroupType], '->Roles');
+}
+
+diag "Create asset with custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+    $m->text_contains('Licensee', 'custom role name');
+    $m->text_contains('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'Some Software',
+            Owner            => 'owner at example.com',
+            $role->GroupType => 'licensee at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset = RT::Asset->new( RT->SystemUser );
+    $asset->Load($id);
+    is $asset->id, $id, "id matches";
+    is $asset->Name, "Some Software", "Name matches";
+    is $asset->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset->RoleAddresses($role->GroupType), 'licensee at example.com', "Licensee matches";
+}
+
+diag "Grant permissions on Licensee";
+{
+    $m->follow_link_ok({ id => "admin-assets-catalogs-select" }, "Admin assets");
+    $m->follow_link_ok({ text => 'Software' }, "Picked a catalog");
+    $m->follow_link_ok({ id => 'page-group-rights' }, "Group rights");
+
+    $m->text_contains('Licensee', 'role group name');
+
+    my $acl_id = $catalog->RoleGroup($role->GroupType)->Id;
+
+    $m->form_name('ModifyGroupRights');
+    $m->tick("SetRights-" . $acl_id . '-RT::Catalog-' . $catalog->id, 'ShowAsset');
+    $m->tick("SetRights-" . $acl_id . '-RT::Catalog-' . $catalog->id, 'ShowCatalog');
+    $m->submit;
+    $m->text_contains("Granted right 'ShowAsset' to Licensee");
+    $m->text_contains("Granted right 'ShowCatalog' to Licensee");
+
+    RT::Principal::InvalidateACLCache();
+}
+
+diag "Create asset without custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+    $m->text_contains('Licensee', 'custom role name');
+    $m->text_contains('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'More Software',
+            Owner            => 'owner at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset2 = RT::Asset->new( RT->SystemUser );
+    $asset2->Load($id);
+    is $asset2->id, $id, "id matches";
+    is $asset2->Name, "More Software", "Name matches";
+    is $asset2->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset2->RoleAddresses($role->GroupType), '', "No Licensee";
+}
+
+diag "Search by custom role";
+{
+    $m->follow_link_ok({ id => "assets-search" }, "Asset search link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->Id } }, "Picked a catalog");
+    $m->submit_form_ok({
+        with_fields => {
+            'Role.' . $role->GroupType => 'licensee at example.com',
+        },
+        button => 'SearchAssets',
+    }, "Search by role");
+
+    $m->text_contains('Some Software', 'search hit');
+    $m->text_lacks('More Software', 'search miss');
+
+    $m->submit_form_ok({
+        with_fields => {
+            'Role.' . $role->GroupType => '',
+            '!Role.' . $role->GroupType => 'licensee at example.com',
+        },
+        button => 'SearchAssets',
+    }, "Search by role");
+
+    $m->text_lacks('Some Software', 'search miss');
+    $m->text_contains('More Software', 'search hit');
+}
+
+diag "Test permissions on Licensee";
+{
+    $m->logout;
+    $m->login('licensee at example.com', 'password');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset->Id);
+    $m->text_contains('Some Software', 'asset name shows on page');
+    $m->text_contains('Licensee', 'role name shows on page');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset2->Id);
+    $m->text_lacks('More Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+}
+
+$m->logout;
+$m->login; # log back in as root
+
+diag "Disable role";
+{
+    $m->follow_link_ok({ id => "admin-custom-roles-select" }, "Custom Role select link");
+    $m->follow_link_ok({ text => 'Licensee' }, "Picked a custom role");
+    $m->submit_form_ok({
+        with_fields => {
+            Enabled => 0,
+        },
+    }, "submitted update form");
+    $m->text_contains('Custom role disabled');
+
+    # refresh cache
+    RT::CustomRoles->RegisterRoles;
+
+    $role->Load($role->Id);
+    is $role->Name, "Licensee", "Name matches";
+    ok $role->Disabled, "now disabled";
+
+    my $catalog_id = $catalog->Id;
+    $catalog = RT::Catalog->new( RT->SystemUser );
+    $catalog->Load($catalog_id);
+    is_deeply([sort $catalog->Roles], [sort 'Contact', 'HeldBy', 'Owner'], '->Roles no longer includes Licensee');
+}
+
+diag "Test permissions on Licensee";
+{
+    $m->logout;
+    $m->login('licensee at example.com', 'password');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset->Id);
+    $m->text_lacks('Some Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset2->Id);
+    $m->text_lacks('More Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+}
+
+$m->logout;
+$m->login; # log back in as root
+
+diag "Create asset with disabled custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
+    $m->text_lacks('Licensee', 'custom role name');
+    $m->text_lacks('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'All Software',
+            Owner            => 'owner at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset3 = RT::Asset->new( RT->SystemUser );
+    $asset3->Load($id);
+    is $asset3->id, $id, "id matches";
+    is $asset3->Name, "All Software", "Name matches";
+    is $asset3->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset3->RoleAddresses($role->GroupType), '', "No Licensee";
+}
+
+done_testing;

commit 5a4779e9249bd4a9971e7738503952cd828937f0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Apr 19 20:23:01 2017 +0000

    Relax requirements about role names
    
    We can't always have the LookupType readily available inside ValidateName
    (e.g. when DBIx::SearchBuilder::Record calls it), and so it's a challenge to
    restrict the names to avoid clashing with builtin roles. We could prohibit
    any builtin role name, but that means you can't create a "Contact" role for
    tickets, nor could you create an AdminCc for assets. So rather than trying
    to thread the needle to restrict role names, just open it up.

diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index 92a0c6308a..e74f34dcf1 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -274,15 +274,6 @@ sub _ValidateName {
         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);
 

commit 1a28f7faeeb183d5ba394e5c2edc0de86a3ed6e1
Author: Jim Brandt <jbrandt at bestpractical.com>
Date:   Wed May 3 14:05:01 2017 -0400

    Add lookup type to custom role admin page listing

diff --git a/share/html/Admin/CustomRoles/index.html b/share/html/Admin/CustomRoles/index.html
index ee4bcf540c..e37f52162a 100644
--- a/share/html/Admin/CustomRoles/index.html
+++ b/share/html/Admin/CustomRoles/index.html
@@ -75,11 +75,12 @@
 <em><&|/l&>No custom roles matching search criteria found.</&></em>
 % } else {
 <& /Elements/CollectionList,
-    OrderBy => 'Name',
-    Order => 'ASC',
+    OrderBy => 'LookupType|Name',
+    Order => 'ASC|ASC',
     Rows  => $Rows,
     %ARGS,
     Format => $Format,
+    DisplayFormat => ($Type? '' : '__FriendlyLookupType__,'). $Format,
     Collection => $roles,
     AllowSorting => 1,
     PassArguments => [qw(
@@ -94,6 +95,7 @@ my $title = loc("Select a Custom Role");
 
 my $roles = RT::CustomRoles->new($session{'CurrentUser'});
 $roles->FindAllRows if $FindDisabled;
+$roles->LimitToLookupType( $Type ) if $Type;
 
 if ( defined $SearchString && length $SearchString ) {
     $roles->Limit(
@@ -112,6 +114,7 @@ my $Rows = RT->Config->Get('AdminSearchResultRows')->{'CustomRoles'} || 50;
 
 </%INIT>
 <%ARGS>
+$Type => ''
 $FindDisabled => 0
 $Format       => undef
 
diff --git a/share/html/Elements/RT__CustomRole/ColumnMap b/share/html/Elements/RT__CustomRole/ColumnMap
index 63ef10bf5f..3e675c6514 100644
--- a/share/html/Elements/RT__CustomRole/ColumnMap
+++ b/share/html/Elements/RT__CustomRole/ColumnMap
@@ -63,7 +63,16 @@ my $COLUMN_MAP = {
             title     => $c, attribute => $c,
             value     => sub { return $_[0]->$c() },
         } }
-        qw(Name Description EntryHint)
+        qw(Name Description LookupType EntryHint)
+    ),
+
+    map(
+        { my $c = $_; my $short = $c; $short =~ s/^Friendly//;
+          $c => {
+            title     => $short, attribute => $short,
+            value     => sub { return $_[0]->$c() },
+        } }
+        qw(FriendlyLookupType FriendlyType FriendlyPattern)
     ),
 
     MaxValues => {

commit fb08f55e8c19f6d7ef5b82240e566ca84ca24713
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed May 17 18:45:06 2017 +0000

    Exclude asset custom roles from ticket search
    
    This covers both search builder and bulk update.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 291934af76..17bb832c49 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1103,6 +1103,7 @@ sub _CustomRoleDecipher {
 
     if ( $field =~ /\D/ ) {
         my $roles = RT::CustomRoles->new( $self->CurrentUser );
+        $roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
         $roles->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
 
         # custom roles are named uniquely, but just in case there are
diff --git a/share/html/Elements/ColumnMap b/share/html/Elements/ColumnMap
index 836d658d8c..dc324291ab 100644
--- a/share/html/Elements/ColumnMap
+++ b/share/html/Elements/ColumnMap
@@ -156,7 +156,12 @@ $WCOLUMN_MAP = $COLUMN_MAP = {
                 my $role_type = $m->notes($key);
                 if (!$role_type) {
                     my $role_obj = RT::CustomRole->new($object->CurrentUser);
-                    $role_obj->Load($role_name);
+                    if ($role_name =~ /^\d+$/) {
+                        $role_obj->Load($role_name);
+                    }
+                    else {
+                        $role_obj->LoadByCols(Name => $role_name, LookupType => $object->CustomFieldLookupType);
+                    }
 
                     RT->Logger->notice("Unable to load custom role $role_name")
                         unless $role_obj->Id;
diff --git a/share/html/Search/Bulk.html b/share/html/Search/Bulk.html
index e5be348696..7d05600176 100644
--- a/share/html/Search/Bulk.html
+++ b/share/html/Search/Bulk.html
@@ -96,6 +96,7 @@
 <td class="value"> <& /Elements/EmailInput, Name => "DeleteAdminCc", Size=> 20, Default => $ARGS{DeleteAdminCc}, AutocompleteType => 'Principals' &> </td></tr>
 
 % my $single_roles = RT::CustomRoles->new($session{CurrentUser});
+% $single_roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 % $single_roles->LimitToSingleValue;
 % $single_roles->LimitToObjectId($_) for keys %$seen_queues;
 % while (my $role = $single_roles->Next) {
@@ -106,6 +107,7 @@
 % }
 
 % my $multi_roles = RT::CustomRoles->new($session{CurrentUser});
+% $multi_roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 % $multi_roles->LimitToMultipleValue;
 % $multi_roles->LimitToObjectId($_) for keys %$seen_queues;
 % while (my $role = $multi_roles->Next) {
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index ce541f889e..17c09b5af3 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -121,6 +121,7 @@ while ( my $CustomField = $CustomFields->Next ) {
 }
 
 my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
+$CustomRoles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 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'});
diff --git a/share/html/Search/Elements/PickCustomRoles b/share/html/Search/Elements/PickCustomRoles
index 38f53a2114..4d120305c4 100644
--- a/share/html/Search/Elements/PickCustomRoles
+++ b/share/html/Search/Elements/PickCustomRoles
@@ -50,6 +50,7 @@
 </%ARGS>
 <%INIT>
 my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
+$CustomRoles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 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'});

commit dff04c749242fcb8db735a0f0817e97b5a271fd3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed May 17 19:11:17 2017 +0000

    Remove custom role name uniqueness restriction
    
    This is especially important now that custom roles can be applied to
    different object types (specifically assets).
    
    Now that we can have multiple custom roles with the same name, we have
    to be a little bit more careful any time we handle a custom role by
    name. Internally custom roles are referenced as RT::CustomRole-$id, so
    it's largely only a concern when interpreting user input. Since we were
    already careful to use LimitToLookupType, there should never be any
    confusion internally between a ticket custom role named Foo and an asset
    custom role named Foo.

diff --git a/lib/RT/Action/Notify.pm b/lib/RT/Action/Notify.pm
index 4798c06899..48703c5574 100644
--- a/lib/RT/Action/Notify.pm
+++ b/lib/RT/Action/Notify.pm
@@ -121,14 +121,17 @@ sub SetRecipients {
             $role->Load( $id );
         }
         else {
-            my $roles = RT::CustomRoles->new( $self->CurrentUser );
+            my $roles = $self->TicketObj->QueueObj->CustomRoles;
             $roles->Limit( FIELD => 'Name', VALUE => $name, 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
+            # 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;
+                if ($roles->Next) {
+                    RT->Logger->error("Ambiguous custom role named '$name' in Notify action for queue #" . $self->TicketObj->Queue . "; skipping. Perhaps specify RT::CustomRole-# instead.");
+                    $role = undef;
+                }
             }
         }
 
diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index e74f34dcf1..e63426dac1 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -250,7 +250,7 @@ sub Load {
 =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.
+a new custom role. Returns undef if it's not valid.
 
 =cut
 
@@ -274,13 +274,6 @@ sub _ValidateName {
         return ($ok, $self->loc("'[_1]' is not a valid name.", $name));
     }
 
-    my $temp = RT::CustomRole->new(RT->SystemUser);
-    $temp->LoadByCols(Name => $name);
-
-    if ( $temp->Name && $temp->id != ($self->id||0))  {
-        return (undef, $self->loc("Role already exists") );
-    }
-
     return (1);
 }
 
diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 17bb832c49..3a5a8a717c 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1106,11 +1106,14 @@ sub _CustomRoleDecipher {
         $roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
         $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
+        # 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;
+            if ($roles->Next) {
+                RT->Logger->error("Ambiguous custom role named '$field' in TicketSQL; skipping. Perhaps specify __CustomRole.{id}__ instead.");
+                $role = undef;
+            }
         }
     }
     else {
diff --git a/sbin/rt-validator.in b/sbin/rt-validator.in
index 484be5f156..1491cc257e 100644
--- a/sbin/rt-validator.in
+++ b/sbin/rt-validator.in
@@ -416,22 +416,6 @@ 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?"
diff --git a/t/customroles/basic.t b/t/customroles/basic.t
index 0ab458483b..10668f073f 100644
--- a/t/customroles/basic.t
+++ b/t/customroles/basic.t
@@ -254,39 +254,6 @@ diag 'role names' if $ENV{'TEST_VERBOSE'};
     my $playground = RT::CustomRole->new(RT->SystemUser);
     ($ok, $msg) = $playground->Create(Name => 'Playground-' . $$, MaxValues => 1);
     ok($ok, "playground role: $msg");
-
-    for my $name (
-        'Programmer-' . $$,
-        'proGRAMMER-' . $$,
-        'Cc',
-        'CC',
-        'AdminCc',
-        'ADMIN CC',
-        'Requestor',
-        'requestors',
-        'Owner',
-        'OWNer',
-    ) {
-        # creating a role with that name should fail
-        my $new = RT::CustomRole->new(RT->SystemUser);
-        ($ok, $msg) = $new->Create(Name => $name, MaxValues => 1);
-        ok(!$ok, "creating a role with duplicate name $name should fail: $msg");
-
-        # updating an existing role with the dupe name should fail too
-        ($ok, $msg) = $playground->SetName($name);
-        ok(!$ok, "updating an existing role with duplicate name $name should fail: $msg");
-        is($playground->Name, 'Playground-' . $$, 'name stayed the same');
-    }
-
-    # make sure we didn't create any new roles
-    my $roles = RT::CustomRoles->new(RT->SystemUser);
-    $roles->UnLimit;
-    is($roles->Count, 3, 'three roles (original two plus playground)');
-
-    is_deeply([sort RT::System->Roles], ['AdminCc', 'Cc', 'Contact', 'HeldBy', 'Owner', 'RT::CustomRole-1', 'RT::CustomRole-2', 'RT::CustomRole-3', 'Requestor'], 'No new System->Roles');
-    is_deeply([sort RT::Queue->Roles], ['AdminCc', 'Cc', 'Owner', 'RT::CustomRole-1', 'RT::CustomRole-2', 'RT::CustomRole-3', 'Requestor'], 'No new Queue->Roles');
-    is_deeply([sort RT::Ticket->Roles], ['AdminCc', 'Cc', 'Owner', 'RT::CustomRole-1', 'RT::CustomRole-2', 'RT::CustomRole-3', 'Requestor'], 'No new Ticket->Roles');
-    is_deeply([sort RT::Queue->ManageableRoleGroupTypes], ['AdminCc', 'Cc', 'RT::CustomRole-2'], 'No new Queue->ManageableRoleGroupTypes');
 }
 
 diag 'load by name and id' if $ENV{'TEST_VERBOSE'};

commit 4ae2bd7ac2b8191276c07e1cb50aa811e4bf5a6a
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu May 20 05:13:52 2021 +0800

    Clear old data when registering the whole custom roles
    
    RegisterRoles is used to refresh custom roles in
    RT::Interface::Web::MaybeRebuildCustomRolesCache, but previously it just
    updated enabled ones and wrongly ignored disabled/deleted ones.
    
    This commit updates the logic to remove existing custom roles first and
    then fill new data from scratch, which fixes the issue.

diff --git a/lib/RT/CustomRoles.pm b/lib/RT/CustomRoles.pm
index 9d92e0e904..b6f2431dd1 100644
--- a/lib/RT/CustomRoles.pm
+++ b/lib/RT/CustomRoles.pm
@@ -98,6 +98,11 @@ subsystem, suitable for system startup.
 sub RegisterRoles {
     my $class = shift;
 
+    for my $type ( keys %RT::Record::Role::Roles::ROLES ) {
+        %{ $RT::Record::Role::Roles::ROLES{$type} } = map { $_ => $RT::Record::Role::Roles::ROLES{$type}{$_} }
+            grep { !/^RT::CustomRole-/ } keys %{$RT::Record::Role::Roles::ROLES{$type}};
+    }
+
     my $roles = $class->new(RT->SystemUser);
     $roles->UnLimit;
 
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index d205e7e4b4..f347bcc743 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -305,7 +305,7 @@ sub Roles {
 }
 
 {
-    my %ROLES;
+    our %ROLES;
     sub _ROLES {
         my $class = ref($_[0]) || $_[0];
         return $ROLES{$class} ||= {};

commit d95fe2d665bb12ba6af73c1607b409aee856d3fa
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 21 04:01:39 2021 +0800

    Show single custom role's name in the result message of adding members
    
    This is for asset custom roles, tickets don't have this issue as the
    result message was customized in RT::Ticket already.

diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index 0e4cb973dd..7d5a02407a 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -1045,7 +1045,7 @@ sub _AddMember {
     }
 
     return (1, $self->loc("[_1] set to [_2]",
-                          $self->loc($self->Name), $new_member_obj->Object->Name) )
+                          $self->Label, $new_member_obj->Object->Name) )
         if $self->SingleMemberRoleGroup;
 
     return ( 1, $self->loc("Member added: [_1]", $new_member_obj->Object->Name) );

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


More information about the rt-commit mailing list