[Rt-commit] rt branch, 4.4/asset-custom-roles, created. rt-4.4.1-9-gbb684f6

Shawn Moore shawn at bestpractical.com
Wed Apr 19 17:17:42 EDT 2017


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

- Log -----------------------------------------------------------------
commit 3eabe161b097840fb458f5426dac5106c2de1b8d
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 3a4dada..14c4610 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -734,5 +734,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 172964893140e74fe84e9faf2676f3e315bc8b84
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 b2e75bb..f5600ca 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -513,6 +513,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 26e7d74b2234107977cba859dc34f6a1d3e26bf9
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 ee56a25..cb37113 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'}
 
@@ -197,7 +198,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
@@ -1245,120 +1245,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 = $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
@@ -1458,20 +1344,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.
@@ -1903,31 +1775,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 0000000..09cd31d
--- /dev/null
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -0,0 +1,251 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2016 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(
+                "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 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 = $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 f9a7324..0d2f1af 100644
--- a/share/html/Admin/CustomFields/Modify.html
+++ b/share/html/Admin/CustomFields/Modify.html
@@ -92,8 +92,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/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html
index 1905cc2..307d868 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/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectCustomFieldLookupType
index 0ef6533..fbb9a86 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 87%
copy from share/html/Admin/Elements/SelectCustomFieldLookupType
copy to share/html/Admin/Elements/SelectLookupType
index 0ef6533..1c76181 100644
--- a/share/html/Admin/Elements/SelectCustomFieldLookupType
+++ b/share/html/Admin/Elements/SelectLookupType
@@ -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 396a5fd16a3bd4e65a31e428ef661b6c1fb05480
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

diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
index 09cd31d..7e90443 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 4b228a095a1aabb3e58d305024dacb90e99d8798
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 16ae12b..d9d1399 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -516,6 +516,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 b23dbf0..4c5e880 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -749,6 +749,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 acf3b3f..75b9e6b 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -545,6 +545,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 9b239ad..559d295 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -534,6 +534,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.2/schema.Oracle b/etc/upgrade/4.4.2/schema.Oracle
new file mode 100644
index 0000000..300bf8d
--- /dev/null
+++ b/etc/upgrade/4.4.2/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.2/schema.Pg b/etc/upgrade/4.4.2/schema.Pg
new file mode 100644
index 0000000..671d871
--- /dev/null
+++ b/etc/upgrade/4.4.2/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.2/schema.SQLite b/etc/upgrade/4.4.2/schema.SQLite
new file mode 100644
index 0000000..ec766a3
--- /dev/null
+++ b/etc/upgrade/4.4.2/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.2/schema.mysql b/etc/upgrade/4.4.2/schema.mysql
new file mode 100644
index 0000000..850f200
--- /dev/null
+++ b/etc/upgrade/4.4.2/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 f1bb313..d11d32a 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')) {
-                # there's no way to apply the custom
-                # role to a queue before that queue is created
-                return 0;
-            }
-            elsif ($object->isa('RT::Ticket')) {
-                # see if the role has been applied to the ticket's queue
-                # 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;
@@ -207,14 +207,8 @@ sub _RegisterAsRole {
                 return 1;
             }
 
-            # custom roles apply to queues, so canonicalize a ticket
-            # into its queue
-            if ($object->isa('RT::Ticket')) {
-                $object = $object->QueueObj;
-            }
-
-            if ($object->isa('RT::Queue')) {
-                return $role->IsAdded($object->Id);
+            if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'AppliesToObjectPredicate')) {
+                return $predicate->($object, $role);
             }
 
             return 0;
@@ -235,7 +229,7 @@ sub _RegisterAsRole {
 sub _UnregisterAsRole {
     my $self = shift;
 
-    RT::Ticket->UnregisterRole($self->GroupType);
+    $self->ObjectTypeFromLookupType->UnregisterRole($self->GroupType);
 }
 
 =head2 Load ID/NAME
@@ -375,7 +369,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 +377,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,15 +394,18 @@ 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 );
     return $rec->Add( %args, CustomRole => $self );
@@ -416,7 +413,7 @@ sub AddToObject {
 
 =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:
 
@@ -424,7 +421,7 @@ Accepts a param hash of:
 
 =item C<ObjectId>
 
-Queue name or id.
+Object id of the class corresponding with L</LookupType>.
 
 =back
 
@@ -437,13 +434,18 @@ 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'} );
@@ -555,6 +557,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.
@@ -609,62 +644,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);
+    my $subgroup_config = $self->LookupTypeRegistration($self->LookupType, 'Subgroup');
+    if ($subgroup_config) {
+        # disable each existant ticket group
+        my $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 $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);
+            }
         }
     }
 }
@@ -686,9 +724,9 @@ sub SetDisabled {
     # if you add a role to queues A and B, add users and privileges and
     # tickets on both, remove the role from B, disable the role, then re-enable
     # the role, we shouldn't re-enable B because it's still removed
-    my $queues = $self->AddedTo;
-    while (my $queue = $queues->Next) {
-        $self->_SetGroupsDisabledForQueue($value, $queue);
+    my $objects = $self->AddedTo;
+    while (my $object = $objects->Next) {
+        $self->_SetGroupsDisabledForObject($value, $object);
     }
 
     $RT::Handle->Commit();
@@ -712,6 +750,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 80af1c1..f5dea59 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 ed5506c..acd3df3 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,29 +144,28 @@ 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) {
-        # there already was a role group for this queue, which means
+        # there already was a role group for this object, which means
         # this was previously added, then removed, and is now being re-added,
-        # which means we have to re-enable the queue group and all the
-        # ticket groups
-        $role->_SetGroupsDisabledForQueue(0, $queue);
+        # which means we have to re-enable the group
+        $role->_SetGroupsDisabledForObject(0, $object);
     }
     else {
         my $group = RT::Group->new($self->CurrentUser);
         my ($ok, $msg) = $group->CreateRoleGroup(
             Name   => $role->GroupType,
-            Object => $queue,
+            Object => $object,
         );
 
         unless ($ok) {
@@ -172,7 +183,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
 
@@ -181,7 +192,7 @@ sub Delete {
 
     $RT::Handle->BeginTransaction;
 
-    $self->CustomRoleObj->_SetGroupsDisabledForQueue(1, $self->QueueObj);
+    $self->CustomRoleObj->_SetGroupsDisabledForObject(1, $self->Object);
 
     # remove the ObjectCustomRole record
     my ($ok, $msg) = $self->SUPER::Delete(@_);
diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 012cf20..fddc90c 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;
     }
     return ($roles);
@@ -1083,6 +1084,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 3fa0557..6457b5b 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -113,6 +113,48 @@ for my $role (sort keys %ROLES) {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Tickets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Queue')) {
+                # there's no way to apply the custom
+                # role to a queue before that queue is created
+                return 0;
+            }
+            elsif ($object->isa('RT::Ticket')) {
+                # see if the role has been applied to the ticket's queue
+                # 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) = @_;
+            # 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/Objects.html b/share/html/Admin/CustomRoles/Objects.html
index 323229c..29e877a 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 9247b2c4e7178c2dd9a7048dcccb5faa1b34a248
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 f5600ca..8006a3d 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -99,6 +99,46 @@ for my $role ('Owner', 'HeldBy', 'Contact') {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Assets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Catalog')) {
+                # there's no way to apply the custom
+                # role to a catalog before that catalog is created
+                return 0;
+            }
+            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) = @_;
+            # 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
diff --git a/share/html/Asset/Elements/AssetSearchPeople b/share/html/Asset/Elements/AssetSearchPeople
index 3b6d4bf..e6c2175 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 5a29b52..ca16584 100644
--- a/share/html/Asset/Elements/EditCatalogPeople
+++ b/share/html/Asset/Elements/EditCatalogPeople
@@ -52,7 +52,7 @@ $Object
 </%init>
 % for my $role ($Object->Roles( ACLOnly => 0 )) {
 <div class="role-<% CSSClass($role) %> role">
-  <h3><% loc($role) %></h3>
+  <h3><% $Object->LabelForRole($role) %></h3>
   <& EditRoleMembers, Group => $Object->RoleGroup($role) &>
 </div>
 % }
diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople
index 658b436..85444cc 100644
--- a/share/html/Asset/Elements/EditPeople
+++ b/share/html/Asset/Elements/EditPeople
@@ -49,12 +49,24 @@
 % for my $role ( $AssetObj->Roles ) {
 <tr class="asset-people-<% CSSClass($role) %>">
 <td class="label">
-<% loc($role) %>:
+<% $AssetObj->LabelForRole($role) %>:
 </td>
 <td class="value" colspan="5">
 <& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 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>
diff --git a/share/html/Asset/Elements/SelectRoleType b/share/html/Asset/Elements/SelectRoleType
index 91eb303..0ec3b94 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 557ffe6..12b7cd9 100644
--- a/share/html/Asset/Elements/ShowPeople
+++ b/share/html/Asset/Elements/ShowPeople
@@ -53,7 +53,7 @@ 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;

commit d09f361f29e9d9dbaac8b1400c57b26e7b07f929
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Apr 19 21:09:27 2017 +0000

    Support custom roles on asset creation
    
    This required adding a little bit more infrastructure around the Roles()
    method passing along arguments to AppliesToObjectPredicate.
    
    The Roles() method lets you pass any arguments, and they will be checked
    for the same truthiness as what the role registration provided (e.g.
    ACLOnly => 0). However we need to pass the catalog object along because
    when you're creating an asset, its ->CatalogObj returns an unloaded
    RT::Catalog record. To be able to do so without breaking the truthiness
    check, any argument which starts with an underscore is not compared
    against the role registration. So we can pass _Catalog from
    /Asset/Elements/EditPeople and use it in RT::Asset's
    AppliesToObjectPredicate.
    
    This didn't apply to custom roles on tickets because /Ticket/Create.html
    iterates over $QueueObj->CustomRoles. Assets instead uses the role system
    more generically, so this fix was required.
    
    Similarly, tickets use their queue to resolve roles on create, so switch
    assets to use their catalog (instead of the asset itself) to resolve
    roles on create.

diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
index 8006a3d..9c4c5fd 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -118,13 +118,20 @@ RT::CustomRole->RegisterLookupType(
             return 0;
         },
         AppliesToObjectPredicate => sub {
-            my ($object, $role) = @_;
+            my ($object, $role, $args) = @_;
+
             # custom roles apply to catalogs, so canonicalize an asset
             # into its catalog
             if ($object->isa('RT::Asset')) {
                 $object = $object->CatalogObj;
             }
 
+            # when we're creating an asset, its ->CatalogObj returns an
+            # unloaded catalog record, so we pass Catalog separately
+            if ($args->{_Catalog} && !$object->Id) {
+                $object = $args->{_Catalog};
+            }
+
             if ($object->isa('RT::Catalog')) {
                 return $role->IsAdded($object->Id);
             }
@@ -293,7 +300,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/CustomRole.pm b/lib/RT/CustomRole.pm
index d11d32a..00c7cc5 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -190,6 +190,7 @@ sub _RegisterAsRole {
         # custom roles can apply to only a subset of queues
         AppliesToObjectPredicate => sub {
             my $object = shift;
+            my $args = shift;
 
             # reload the role to avoid capturing $self across requests
             my $role = RT::CustomRole->new(RT->SystemUser);
@@ -208,7 +209,7 @@ sub _RegisterAsRole {
             }
 
             if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'AppliesToObjectPredicate')) {
-                return $predicate->($object, $role);
+                return $predicate->($object, $role, $args);
             }
 
             return 0;
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 14c4610..dc0b552 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -282,11 +282,12 @@ sub Roles {
             grep {
                 my $ok = 1;
                 for my $k (keys %attr) {
+                    next if $k =~ /^_/;
                     $ok = 0, last if $attr{$k} xor $_->[1]{$k};
                 }
                 $ok }
             grep { !$_->[1]{AppliesToObjectPredicate}
-                 or $_->[1]{AppliesToObjectPredicate}->($self) }
+                 or $_->[1]{AppliesToObjectPredicate}->($self, \%attr) }
              map { [ $_, $self->Role($_) ] }
             keys %{ $self->_ROLES };
 }
diff --git a/share/html/Asset/Create.html b/share/html/Asset/Create.html
index d059ec9..7db87c2 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,
+            } $asset->Roles(_Catalog => $catalog),
         );
 
         # Handle basic fields
diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople
index 85444cc..aefe52c 100644
--- a/share/html/Asset/Elements/EditPeople
+++ b/share/html/Asset/Elements/EditPeople
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <table border="0" cellpadding="0" cellspacing="0">
-% for my $role ( $AssetObj->Roles ) {
+% for my $role ( $AssetObj->Roles(_Catalog => $CatalogObj) ) {
 <tr class="asset-people-<% CSSClass($role) %>">
 <td class="label">
 <% $AssetObj->LabelForRole($role) %>:
@@ -73,4 +73,5 @@
 
 <%args>
 $AssetObj
+$CatalogObj
 </%args>

commit d0c69db6635b0236f6c463b2dc877fa97b5feaf2
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 0000000..314041f
--- /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 0000000..a550b6c
--- /dev/null
+++ b/t/customroles/web-assets.t
@@ -0,0 +1,242 @@
+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_like(qr/Object created/, "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->submit_form_ok({
+        with_fields => {
+            "SetRights-" . $acl_id . '-RT::Catalog-' . $catalog->id => 'ShowAsset',
+        },
+    }, "submitted rights form");
+    $m->text_contains("Granted right 'ShowAsset' 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";
+
+    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";
+}
+
+undef $m;
+done_testing;

commit bb684f6f65251d78b4a46984507e5307142330fa
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 00c7cc5..8c9d69e 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -277,15 +277,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);
 

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


More information about the rt-commit mailing list