diff options
author | Jim Brandt <jbrandt@bestpractical.com> | 2022-10-21 21:31:36 +0300 |
---|---|---|
committer | Jim Brandt <jbrandt@bestpractical.com> | 2022-10-21 21:31:36 +0300 |
commit | d740e2ca83ebef758296a5725a359ecf1fc75f65 (patch) | |
tree | be9fe7b169d65e9540eaa12234ca9a609733137b | |
parent | d52b11cb513a16ba7807435b8bc0257ce42ddf36 (diff) | |
parent | a126fafd8b105f279a9b5d2829f0993790d63c6d (diff) |
Merge branch '5.0/asset-custom-roles' into 5.0-trunk
49 files changed, 1573 insertions, 353 deletions
diff --git a/etc/schema.Oracle b/etc/schema.Oracle index 324f790d19..7b7ac07475 100644 --- a/etc/schema.Oracle +++ b/etc/schema.Oracle @@ -520,6 +520,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 9f34ec4b0a..0c81859312 100644 --- a/etc/schema.Pg +++ b/etc/schema.Pg @@ -753,6 +753,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 d2e455f9e5..2811ed2c4c 100644 --- a/etc/schema.SQLite +++ b/etc/schema.SQLite @@ -549,6 +549,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 f773ffd472..15868174dc 100644 --- a/etc/schema.mysql +++ b/etc/schema.mysql @@ -538,6 +538,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/5.0.4/schema.Oracle b/etc/upgrade/5.0.4/schema.Oracle new file mode 100644 index 0000000000..300bf8d8b6 --- /dev/null +++ b/etc/upgrade/5.0.4/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/5.0.4/schema.Pg b/etc/upgrade/5.0.4/schema.Pg new file mode 100644 index 0000000000..671d871f45 --- /dev/null +++ b/etc/upgrade/5.0.4/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/5.0.4/schema.SQLite b/etc/upgrade/5.0.4/schema.SQLite new file mode 100644 index 0000000000..ec766a33cd --- /dev/null +++ b/etc/upgrade/5.0.4/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/5.0.4/schema.mysql b/etc/upgrade/5.0.4/schema.mysql new file mode 100644 index 0000000000..850f200953 --- /dev/null +++ b/etc/upgrade/5.0.4/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/Action/Notify.pm b/lib/RT/Action/Notify.pm index f6fcbc0654..e1ad1b4458 100644 --- a/lib/RT/Action/Notify.pm +++ b/lib/RT/Action/Notify.pm @@ -122,6 +122,7 @@ sub SetRecipients { } else { my $roles = RT::CustomRoles->new( $self->CurrentUser ); + $roles->LimitToLookupType( RT::Ticket->CustomFieldLookupType ); $roles->Limit( FIELD => 'Name', VALUE => $name, CASESENSITIVE => 0 ); # custom roles are named uniquely, but just in case there are diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm index e313db5d63..c29e58f67d 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 @@ -262,7 +304,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/Assets.pm b/lib/RT/Assets.pm index 046a834e9e..ca7c355be1 100644 --- a/lib/RT/Assets.pm +++ b/lib/RT/Assets.pm @@ -89,6 +89,7 @@ our %FIELD_METADATA = ( HeldByGroup => [ 'MEMBERSHIPFIELD' => 'HeldBy', ], #loc_left_pair Contact => [ 'WATCHERFIELD' => 'Contact', ], #loc_left_pair ContactGroup => [ 'MEMBERSHIPFIELD' => 'Contact', ], #loc_left_pair + CustomRole => [ 'WATCHERFIELD' ], # loc_left_pair CustomFieldValue => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair CustomField => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair @@ -1217,6 +1218,47 @@ sub _StringLimit { ); } +=head2 _CustomRoleDecipher + +Try and turn a custom role descriptor (e.g. C<CustomRole.{Engineer}>) into +(role, column, original name). + +=cut + +sub _CustomRoleDecipher { + my ( $self, $string ) = @_; + + # $column could be core fields like "EmailAddress" or CFs like + # "CustomField.{Department}", the CF format is used in OrderByCols. + my ( $field, $column ) = ( $string =~ /^\{(.+?)\}(?:\.(.+))?$/ ); + + my $role; + + if ( $field =~ /\D/ ) { + my $roles = RT::CustomRoles->new( $self->CurrentUser ); + $roles->LimitToLookupType( RT::Asset->CustomFieldLookupType ); + $roles->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 ); + + # in case there are multiple matches, bail out as we + # don't know which one to use + $role = $roles->First; + if ($role) { + if ( $roles->Next ) { + RT->Logger->error( + "Ambiguous custom role named '$field' in AssetSQL; skipping. Perhaps specify __CustomRole.{id}__ instead." + ); + $role = undef; + } + } + } + else { + $role = RT::CustomRole->new( $self->CurrentUser ); + $role->Load($field); + } + + return ( $role, $column, $field ); +} + =head2 _WatcherLimit Handle watcher limits. (Requestor, CC, etc..) @@ -1238,18 +1280,25 @@ sub _WatcherLimit { my $meta = $FIELD_METADATA{ $field }; my $type = $meta->[1] || ''; my $class = $meta->[2] || 'Asset'; + my $column = $rest{SUBKEY}; + + if ($field eq 'CustomRole') { + my ($role, $col, $original_name) = $self->_CustomRoleDecipher( $column ); + $column = $col || 'id'; + $type = $role ? $role->GroupType : $original_name; + } # Bail if the subfield is not allowed - if ( $rest{SUBKEY} - and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}}) + if ( $column + and not grep { $_ eq $column } @{$SEARCHABLE_SUBFIELDS{'User'}}) { - die "Invalid watcher subfield: '$rest{SUBKEY}'"; + die "Invalid watcher subfield: '$column'"; } $self->RoleLimit( TYPE => $type, CLASS => "RT::$class", - FIELD => $rest{SUBKEY}, + FIELD => $column, OPERATOR => $op, VALUE => $value, SUBCLAUSE => "assetsql", diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm index 3f78fc216b..ac7b6d89e4 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'} @@ -218,7 +219,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 @@ -1414,120 +1414,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 Object|Class Name, Queue Name|Catalog Name Returns a (sorted and lowercased) list of the groupings in which this custom @@ -1640,20 +1526,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. @@ -2141,31 +2013,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/CustomRole.pm b/lib/RT/CustomRole.pm index fa4ec0f747..cf22198491 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, @_, ); @@ -99,13 +104,16 @@ sub Create { } { - my ($val, $msg) = $self->_ValidateName( $args{'Name'} ); + my ($val, $msg) = $self->_ValidateName( $args{'Name'}, $args{'LookupType'} ); return ($val, $msg) unless $val; } $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,10 @@ 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 ( $role->Id ) { + if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'CreateGroupPredicate')) { + return $predicate->($object, $role); + } } return 0; @@ -205,16 +207,10 @@ 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; + if ( $role->Id ) { + if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'AppliesToObjectPredicate')) { + return $predicate->($object, $role); } - - return $role->IsAdded( $object->Id ); } return 0; @@ -235,7 +231,7 @@ sub _RegisterAsRole { sub _UnregisterAsRole { my $self = shift; - RT::Ticket->UnregisterRole($self->GroupType); + $self->ObjectTypeFromLookupType->UnregisterRole($self->GroupType); } =head2 Load ID/NAME @@ -265,8 +261,9 @@ a new custom role. Returns undef if there's already a role by that name. sub ValidateName { my $self = shift; my $name = shift; + my $type = shift || $self->LookupType || 'RT::Queue-RT::Ticket'; - my ($ok, $msg) = $self->_ValidateName($name); + my ($ok, $msg) = $self->_ValidateName($name, $type); return $ok ? 1 : 0; } @@ -274,6 +271,7 @@ sub ValidateName { sub _ValidateName { my $self = shift; my $name = shift; + my $type = shift || $self->LookupType || 'RT::Queue-RT::Ticket'; return (undef, "Role name is required") unless length $name; @@ -282,17 +280,34 @@ 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 + if ( $type eq 'RT::Queue-RT::Ticket' ) { + # These roles are ticket builtin, so avoid any potential confusion + if ( + $name =~ m{^( cc | admin[ ]?cc | requestors? | owner - ) $}xi) { - return (undef, $self->loc("Role already exists") ); + ) $}xi + ) + { + return ( undef, $self->loc("Role already exists") ); + } + } + else { + # These roles are asset builtin, so avoid any potential confusion + if ( + $name =~ m{^( heldby + | contacts? + | owner + ) $}xi + ) + { + return ( undef, $self->loc("Role already exists") ); + } } my $temp = RT::CustomRole->new(RT->SystemUser); - $temp->LoadByCols(Name => $name); + $temp->LoadByCols(Name => $name, LookupType => $type); if ( $temp->Name && $temp->id != ($self->id||0)) { return (undef, $self->loc("Role already exists") ); @@ -301,6 +316,23 @@ sub _ValidateName { return (1); } +=head2 ValidateLookupType TYPE + +Takes a custom role lookup type. Returns true unless there's another role +with the same name and lookup type. + +=cut + +sub ValidateLookupType { + my $self = shift; + my $type = shift; + if ( $self->Id && lc $self->LookupType ne lc $type ) { + return $self->ValidateName( $self->Name, $type ); + } + return 1; +} + + =head2 Delete Delete this object. You should Disable instead. @@ -375,7 +407,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 +415,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 +432,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 +463,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 +476,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 +603,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 +690,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); + } } } } @@ -753,6 +831,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 f49a9f9e4d..f9f01db2fa 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; @@ -156,6 +161,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/Group.pm b/lib/RT/Group.pm index 7f9854a204..9fc202d001 100644 --- a/lib/RT/Group.pm +++ b/lib/RT/Group.pm @@ -1074,7 +1074,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) ); diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm index 6e827ffe28..51c6d2fca8 100644 --- a/lib/RT/Interface/Web.pm +++ b/lib/RT/Interface/Web.pm @@ -4360,11 +4360,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/ObjectCustomRole.pm b/lib/RT/ObjectCustomRole.pm index c66161044f..554004134b 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 => '5.2' ); + 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 @@ -194,7 +206,7 @@ sub FindDependencies { $self->SUPER::FindDependencies($walker, $deps); $deps->Add( out => $self->CustomRoleObj ); - $deps->Add( out => $self->QueueObj ); + $deps->Add( out => $self->Object ); } sub Serialize { diff --git a/lib/RT/ObjectCustomRoles.pm b/lib/RT/ObjectCustomRoles.pm index 9baabb514b..64352fc94b 100644 --- a/lib/RT/ObjectCustomRoles.pm +++ b/lib/RT/ObjectCustomRoles.pm @@ -106,6 +106,25 @@ sub LimitToObjectId { ); } +sub LimitToLookupType { + my $self = shift; + my $lookup = shift; + + $self->{'_crs_alias'} ||= $self->Join( + ALIAS1 => 'main', + FIELD1 => 'CustomRole', + TABLE2 => 'CustomRoles', + FIELD2 => 'id', + ); + $self->Limit( + ALIAS => $self->{'_crs_alias'}, + FIELD => 'LookupType', + OPERATOR => '=', + VALUE => $lookup, + ); +} + + RT::Base->_ImportOverlays(); 1; diff --git a/lib/RT/Principal.pm b/lib/RT/Principal.pm index 27200c3c0f..48ab66e13f 100644 --- a/lib/RT/Principal.pm +++ b/lib/RT/Principal.pm @@ -447,7 +447,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; @@ -699,7 +699,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/Queue.pm b/lib/RT/Queue.pm index 8aba033035..1f3af9c9d0 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 { @@ -1086,6 +1087,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/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm new file mode 100644 index 0000000000..4f9fa2ba1a --- /dev/null +++ b/lib/RT/Record/Role/LookupType.pm @@ -0,0 +1,290 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC +# <sales@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 OPTIONS + +Tell RT that a certain object accepts records of this role via a lookup +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: + + '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 $options = shift; + + die "RegisterLookupType is a class method" if blessed($class); + + $options = { + FriendlyName => $options, + } if !ref($options); + + $REGISTRY{$class}{$path} = $options; +} + +=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 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}}; + + my $ret = $REGISTRY{$class}{$path}{$option}; + return $ret; +} + +=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; + + if (my $friendly = $self->LookupTypeRegistration($lookup, 'FriendlyName')) { + return $self->loc($friendly); + } + + 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/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm index ffcfa5240d..44466123d2 100644 --- a/lib/RT/Record/Role/Roles.pm +++ b/lib/RT/Record/Role/Roles.pm @@ -308,16 +308,16 @@ 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; } { - my %ROLES; + our %ROLES; sub _ROLES { my $class = ref($_[0]) || $_[0]; return $ROLES{$class} ||= {}; @@ -851,4 +851,48 @@ you see faster create times. =cut +=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); + my ( $ret, $msg ) = $role->Load($id); + if ( $ret ) { + return $role; + } + else { + RT->Logger->warning("Couldn't load custom role #$id: $msg"); + } + } + + return undef; +} + + +=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; +} + 1; diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm index d63881f79e..5592929bb3 100644 --- a/lib/RT/Ticket.pm +++ b/lib/RT/Ticket.pm @@ -114,6 +114,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 => {}, @@ -830,25 +874,6 @@ sub CcAddresses { return $self->RoleAddresses('Cc'); } -=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; - - unless ( $self->CurrentUserHasRight('ShowTicket') ) { - return undef; - } - return ( $self->RoleGroup($role)->MemberEmailAddressesAsString); -} - - - =head2 Requestor Takes nothing. @@ -3002,7 +3027,7 @@ sub CurrentUserCanSee { my ($what, $txn) = @_; return 0 unless $self->CurrentUserHasRight('ShowTicket'); - return 1 if $what ne "Transaction"; + return 1 if ( $what // '' ) ne "Transaction"; # If it's a comment, we need to be extra special careful my $type = $txn->__Value('Type'); diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm index 1278f3b000..183932117f 100644 --- a/lib/RT/Tickets.pm +++ b/lib/RT/Tickets.pm @@ -1112,6 +1112,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/sbin/rt-validator.in b/sbin/rt-validator.in index fa6730547b..ba67577e23 100644 --- a/sbin/rt-validator.in +++ b/sbin/rt-validator.in @@ -420,7 +420,7 @@ push @CHECKS, 'User Defined Group Name uniqueness' => sub { push @CHECKS, 'Custom Role Name uniqueness' => sub { return check_uniqueness( 'CustomRoles', - columns => ['Name'], + columns => ['Name', 'LookupType'], action => sub { return unless prompt( 'Rename', "Found a custom role with a non-unique Name." diff --git a/share/html/Admin/CustomFields/Modify.html b/share/html/Admin/CustomFields/Modify.html index 2cfc5be13e..daef955a53 100644 --- a/share/html/Admin/CustomFields/Modify.html +++ b/share/html/Admin/CustomFields/Modify.html @@ -96,8 +96,10 @@ % } <&| /Elements/LabeledValue, Label => loc("Applies to") &> - <& /Admin/Elements/SelectCustomFieldLookupType, + <& /Admin/Elements/SelectLookupType, + Class => 'RT::CustomField', Name => "LookupType", + Object => $CustomFieldObj, Default => $CustomFieldObj->LookupType || $LookupType, &> </&> diff --git a/share/html/Admin/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html index 115a64588d..35b14b7fb9 100644 --- a/share/html/Admin/CustomRoles/Modify.html +++ b/share/html/Admin/CustomRoles/Modify.html @@ -64,6 +64,14 @@ <input class="form-control" type="text" name="Description" value="<% $Create ? "" : $RoleObj->Description || $Description || '' %>" size="60" /> </&> +<&| /Elements/LabeledValue, Label => loc('Applies To') &> + <& /Admin/Elements/SelectLookupType, + Name => "LookupType", + Object => $RoleObj, + Default => $RoleObj->LookupType || $LookupType, + &> +</&> + <&| /Elements/LabeledValue, Label => loc("Entry Hint") &> <input class="form-control" type="text" name="EntryHint" value="<% $Create ? "" : $RoleObj->EntryHint || $EntryHint || '' %>" size="60" /> </&> @@ -125,7 +133,7 @@ $EnabledChecked = 'checked="checked"'; unless ($Create) { if ( defined $id && $id eq 'new' ) { - my ($val, $msg) = $RoleObj->Create( Name => $Name ); + my ($val, $msg) = $RoleObj->Create( Name => $Name, LookupType => $LookupType ); if (!$val) { $Create = 1; # Create failed, so bring us back to step 1 push @results, $msg; @@ -140,7 +148,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') { @@ -198,4 +206,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 c8bc2f7a27..d3e954b6d0 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); diff --git a/share/html/Admin/CustomRoles/index.html b/share/html/Admin/CustomRoles/index.html index c366cfbab7..f308d6b4af 100644 --- a/share/html/Admin/CustomRoles/index.html +++ b/share/html/Admin/CustomRoles/index.html @@ -92,11 +92,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( @@ -110,6 +111,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( @@ -128,6 +130,7 @@ my $Rows = RT->Config->Get('AdminSearchResultRows')->{'CustomRoles'} || 50; </%INIT> <%ARGS> +$Type => '' $FindDisabled => 0 $Format => undef diff --git a/share/html/Admin/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectCustomFieldLookupType index f43d543829..95d7970b83 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 class="form-control selectpicker" 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 => '5.4', + Instead => 'SelectLookupType', +); </%INIT> -<%ARGS> -$Default=> '' -$Name => 'LookupType' -</%ARGS> diff --git a/share/html/Admin/Elements/SelectLookupType b/share/html/Admin/Elements/SelectLookupType new file mode 100644 index 0000000000..7b470555cd --- /dev/null +++ b/share/html/Admin/Elements/SelectLookupType @@ -0,0 +1,61 @@ +%# BEGIN BPS TAGGED BLOCK {{{ +%# +%# COPYRIGHT: +%# +%# This software is Copyright (c) 1996-2022 Best Practical Solutions, LLC +%# <sales@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 }}} +<select class="form-control selectpicker" name="<%$Name%>"> +%for my $option ($Object->LookupTypes) { +<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $Object->FriendlyLookupType($option) %></option> +%} +</select> +<%INIT> +$Object ||= $Class->new($session{'CurrentUser'}); +</%INIT> +<%ARGS> +$Default => '' +$Name => 'LookupType' +$Object => undef +$Class => '' +</%ARGS> diff --git a/share/html/Asset/Create.html b/share/html/Asset/Create.html index f5e91da737..c71526cc61 100644 --- a/share/html/Asset/Create.html +++ b/share/html/Asset/Create.html @@ -65,7 +65,7 @@ <div class="col-6"> <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people", title_class => "inverse" &> - <& Elements/EditPeople, %ARGS, AssetObj => $asset &> + <& Elements/EditPeople, %ARGS, AssetObj => $asset, CatalogObj => $catalog &> </&> </div> </div> @@ -156,7 +156,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 fece788d36..32022d0b1a 100644 --- a/share/html/Asset/Elements/AssetSearchPeople +++ b/share/html/Asset/Elements/AssetSearchPeople @@ -46,10 +46,10 @@ %# %# END BPS TAGGED BLOCK }}} <&| /Widgets/TitleBox, class => "asset-search-people", title => loc('People') &> -% for my $role (RT::Asset->Roles) { +% for my $role ($CatalogObj->Roles) { <div class="asset-role-<% CSSClass($role) %> form-row"> <div class="label col-2"> - <% loc($role) %> + <% RT::Asset->LabelForRole($role) %> </div> <div class="value col-4"> <input class="form-control" type="text" id="Role.<% $role %>" name="Role.<% $role %>" diff --git a/share/html/Asset/Elements/EditCatalogPeople b/share/html/Asset/Elements/EditCatalogPeople index 08c7e00bc7..d52663c439 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> % } diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople index 8db9a7f1f9..79120caa6b 100644 --- a/share/html/Asset/Elements/EditPeople +++ b/share/html/Asset/Elements/EditPeople @@ -45,12 +45,19 @@ %# those contributions and any derivatives thereof. %# %# END BPS TAGGED BLOCK }}} -% for my $role ( $AssetObj->Roles ) { - <&| /Elements/LabeledValue, Label => loc($role), Class => "asset-people-".CSSClass($role) &> - <& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, ($AssetObj->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &> +% for my $role ( $object->Roles ) { +% my $custom_role = $object->CustomRoleObj($role); +% my $hint = $custom_role ? $custom_role->EntryHint : ''; + <&| /Elements/LabeledValue, Label => $object->LabelForRole($role), Class => "asset-people-".CSSClass($role), LabelTooltip => $hint &> + <& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, ($object->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &> </&> % } +<%init> +my $object = $AssetObj->Id ? $AssetObj : $CatalogObj; +</%init> + <%args> $AssetObj +$CatalogObj </%args> diff --git a/share/html/Asset/Elements/EditRoleMembers b/share/html/Asset/Elements/EditRoleMembers index a99b66e322..e0b109529b 100644 --- a/share/html/Asset/Elements/EditRoleMembers +++ b/share/html/Asset/Elements/EditRoleMembers @@ -46,17 +46,20 @@ %# %# 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 list-group list-group-compact"> % my $Users = $Group->UserMembersObj( Recursively => $Recursively ); -% if ($Group->SingleMemberRoleGroup) { +% if ($Object->Role($Role)->{Single}) { +% my $user = $Users->First || RT->Nobody; <li class="list-group-item"> - <input class="form-control selectpicker" type="text" value="<% $Users->First->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" /> + <input class="form-control selectpicker" type="text" value="<% $user->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" /> </li> % } else { % while ( my $user = $Users->Next ) { diff --git a/share/html/Asset/Elements/SelectRoleType b/share/html/Asset/Elements/SelectRoleType index 17f296d813..953232f483 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 6b35fdf2e6..fb505decae 100644 --- a/share/html/Asset/Elements/ShowPeople +++ b/share/html/Asset/Elements/ShowPeople @@ -54,14 +54,14 @@ my $CatalogObj = $AssetObj->CatalogObj; % for my $role ($AssetObj->Roles) { <div class="form-row"> <div class="label col-3"> - <% loc($role) %>: + <% $AssetObj->LabelForRole($role) %>: </div> <div class="value col-9"> <div class="user-accordion accordion"> % 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; % if ( $user->id != RT->Nobody->id ) { <& ShowRoleMembers, Group => $AssetObj->RoleGroup($role), Role => $role &> % } else { diff --git a/share/html/Asset/Elements/ShowRoleMembers b/share/html/Asset/Elements/ShowRoleMembers index e050b9117e..ab134f2d1a 100644 --- a/share/html/Asset/Elements/ShowRoleMembers +++ b/share/html/Asset/Elements/ShowRoleMembers @@ -51,7 +51,7 @@ % next if $user->id == RT->Nobody->id; <div class="accordion-item"> - <span class="accordion-title collapsed toggle" data-toggle="collapse" data-target="#<% $Role %>-user-<% $user->id %>" aria-expanded="false" aria-controls="<% $Role %>-user-<% $user->id %>" id="<% $Role %>-user-<% $user->id %>-title" > + <span class="accordion-title collapsed toggle" data-toggle="collapse" data-target="[id='<% $Role %>-user-<% $user->id %>']" aria-expanded="false" aria-controls="<% $Role %>-user-<% $user->id %>" id="<% $Role %>-user-<% $user->id %>-title" > % if ($Title) { <& /Elements/ShowUser, User => $user, Link => 1 &> diff --git a/share/html/Asset/Search/Bulk.html b/share/html/Asset/Search/Bulk.html index f1331b3af2..43b716296d 100644 --- a/share/html/Asset/Search/Bulk.html +++ b/share/html/Asset/Search/Bulk.html @@ -124,29 +124,29 @@ </&> <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people asset-bulk-people", title_class => "inverse" &> -% for my $rname ( $asset->Roles( ACLOnly => 0 ) ) { -% my $role = $asset->Role( $rname ); -% if ( $role->{'Single'} ) { +% for my $rname ( $asset->Roles( ACLOnly => 0, Single => 1 ), map { $_->GroupType } @{ $single_roles->ItemsArrayRef } ) { % my $input = "SetRoleMember-$rname"; <div class="form-row"> <div class="col-6"> - <&| /Elements/LabeledValue, Label => loc($rname) &> + <&| /Elements/LabeledValue, Label => RT::Asset->LabelForRole($rname) &> <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" /> </&> </div> </div> -% } else { +% } + +% for my $rname ( $asset->Roles( ACLOnly => 0, Single => 0 ), map { $_->GroupType } @{ $multi_roles->ItemsArrayRef } ) { % my $input = "AddRoleMember-$rname"; <div class="form-row"> <div class="col-6"> - <&| /Elements/LabeledValue, Label => loc("Add [_1]", loc($rname)) &> + <&| /Elements/LabeledValue, Label => loc("Add [_1]", RT::Asset->LabelForRole($rname)) &> <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" /> </&> </div> % $input = "RemoveRoleMember-$rname"; <div class="col-6"> - <&| /Elements/LabeledValue, Label => loc("Remove [_1]", loc($rname)) &> + <&| /Elements/LabeledValue, Label => loc("Remove [_1]", RT::Asset->LabelForRole($rname)) &> <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" /> <div class="custom-control custom-checkbox"> @@ -157,7 +157,6 @@ </div> </div> % } -% } % my $people_cfs = $cfs->Clone; % $people_cfs->LimitToGrouping( 'RT::Asset' => 'People'); % if ( $people_cfs->Count ) { @@ -229,6 +228,9 @@ delete $ARGS{$_} foreach grep { $ARGS{$_} =~ /^$/ } keys %ARGS; $DECODED_ARGS->{'UpdateAssetAll'} = 1 unless @UpdateAsset; my $cfs; +my $single_roles = RT::CustomRoles->new( $session{CurrentUser} ); +my $multi_roles = RT::CustomRoles->new( $session{CurrentUser} ); + if ( $ARGS{Query} ) { $cfs = RT::CustomFields->new( $session{'CurrentUser'} ); $cfs->LimitToLookupType( RT::Asset->CustomFieldLookupType ); @@ -252,9 +254,26 @@ if ( $ARGS{Query} ) { } } $cfs->LimitToGlobalOrObjectId(@ids); + + if ( @ids ) { + $single_roles->LimitToObjectId($_) for @ids; + $multi_roles->LimitToObjectId($_) for @ids; + } } else { $cfs = $catalog_obj->AssetCustomFields; + $single_roles->LimitToObjectId( $catalog_obj->Id ); + $multi_roles->LimitToObjectId( $catalog_obj->Id ); +} + +if ( $single_roles->_isLimited ) { + $single_roles->LimitToLookupType( RT::Asset->CustomFieldLookupType ); + $single_roles->LimitToSingleValue; +} + +if ( $multi_roles->_isLimited ) { + $multi_roles->LimitToLookupType( RT::Asset->CustomFieldLookupType ); + $multi_roles->LimitToMultipleValue; } if ( $ARGS{'CreateLinkedTicket'} ){ diff --git a/share/html/Elements/ColumnMap b/share/html/Elements/ColumnMap index 8a5b89fcb1..da1f866efe 100644 --- a/share/html/Elements/ColumnMap +++ b/share/html/Elements/ColumnMap @@ -294,7 +294,12 @@ $WCOLUMN_MAP = $COLUMN_MAP = { my $role_obj = $m->notes($key); if (!$role_obj) { $role_obj = RT::CustomRole->new($_[0]->CurrentUser); - $role_obj->Load($role_name); + if ($role_name =~ /^\d+$/) { + $role_obj->Load($role_name); + } + else { + $role_obj->LoadByCols(Name => $role_name, LookupType => $_[0]->CustomFieldLookupType); + } RT->Logger->notice("Unable to load custom role $role_name") unless $role_obj->Id; diff --git a/share/html/Elements/RT__CustomRole/ColumnMap b/share/html/Elements/RT__CustomRole/ColumnMap index 758b24bd89..2c2f7ba6e2 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 => { diff --git a/share/html/Search/Bulk.html b/share/html/Search/Bulk.html index 1b9a2855f0..d038d587f5 100644 --- a/share/html/Search/Bulk.html +++ b/share/html/Search/Bulk.html @@ -154,6 +154,7 @@ </&> % 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) { @@ -163,6 +164,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 15f0351c18..12e630d857 100644 --- a/share/html/Search/Elements/BuildFormatString +++ b/share/html/Search/Elements/BuildFormatString @@ -144,6 +144,19 @@ elsif ( $Class eq 'RT::Assets' ) { push @fields, "CustomFieldView.{" . $CustomField->Name . "}"; } + my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'} ); + foreach my $id ( keys %catalogs ) { + + # Gotta load up the $catalog object, since catalogs get stored by name now. + my $catalog = RT::Catalog->new( $session{'CurrentUser'} ); + $catalog->Load($id); + next unless $catalog->Id; + $CustomRoles->LimitToObjectId( $catalog->Id ); + } + $CustomRoles->LimitToLookupType( RT::Asset->CustomFieldLookupType ) if $CustomRoles->_isLimited; + while ( my $role = $CustomRoles->Next ) { + push @fields, 'CustomRole.{' . $role->Name . '}'; + } } else { $Format ||= RT->Config->Get('DefaultSearchResultFormat'); @@ -211,6 +224,7 @@ else { next unless $queue->Id; $CustomRoles->LimitToObjectId( $queue->Id ); } + $CustomRoles->LimitToLookupType(RT::Ticket->CustomFieldLookupType) if $CustomRoles->_isLimited; my @user_fields = qw/id Name EmailAddress Organization RealName City Country/; my $user_cfs = RT::CustomFields->new( $session{CurrentUser} ); diff --git a/share/html/Search/Elements/PickBasics b/share/html/Search/Elements/PickBasics index 4e134b1e81..22ff9d67a0 100644 --- a/share/html/Search/Elements/PickBasics +++ b/share/html/Search/Elements/PickBasics @@ -275,7 +275,7 @@ elsif ( $Class eq 'RT::Assets' ) { Field => { Type => 'component', Path => 'SelectPersonType', - Arguments => { Default => 'Owner', Class => 'RT::Assets' }, + Arguments => { Default => 'Owner', Class => 'RT::Assets', Catalogs => \%catalogs }, }, Op => { Type => 'component', diff --git a/share/html/Search/Elements/PickCustomRoles b/share/html/Search/Elements/PickCustomRoles index b1acbad2f3..ab86e39f04 100644 --- a/share/html/Search/Elements/PickCustomRoles +++ b/share/html/Search/Elements/PickCustomRoles @@ -47,18 +47,35 @@ %# END BPS TAGGED BLOCK }}} <%ARGS> %queues => () +%catalogs => () </%ARGS> <%INIT> RT->Deprecated( Message => '/Search/Elements/PickCustomRoles is obsolete', Remove => '5.2' ); my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'}); -foreach my $id (keys %queues) { - # Gotta load up the $queue object, since queues get stored by name now. - my $queue = RT::Queue->new($session{'CurrentUser'}); - $queue->Load($id); - next unless $queue->Id; - $CustomRoles->LimitToObjectId($queue->Id); +if ( %queues ) { + foreach my $id (keys %queues) { + # Gotta load up the $queue object, since queues get stored by name now. + my $queue = RT::Queue->new($session{'CurrentUser'}); + $queue->Load($id); + next unless $queue->Id; + $CustomRoles->LimitToObjectId($queue->Id); + } + # If there are no referenced queues, do not limit LookupType to return 0 custom roles. + $CustomRoles->LimitToLookupType( RT::Ticket->CustomFieldLookupType ) if $CustomRoles->_isLimited; } +elsif ( %catalogs ) { + foreach my $id (keys %catalogs) { + # Gotta load up the $catalog object, since catalogs get stored by name now. + my $catalog = RT::Catalog->new($session{'CurrentUser'}); + $catalog->Load($id); + next unless $catalog->Id; + $CustomRoles->LimitToObjectId($catalog->Id); + } + # If there are no referenced catalogs, do not limit LookupType to return 0 custom roles. + $CustomRoles->LimitToLookupType( RT::Asset->CustomFieldLookupType ) if $CustomRoles->_isLimited; +} + $m->callback( CallbackName => 'MassageCustomRoles', CustomRoles => $CustomRoles, diff --git a/share/html/Search/Elements/SelectPersonType b/share/html/Search/Elements/SelectPersonType index 57e4c8a209..9e029f78c6 100644 --- a/share/html/Search/Elements/SelectPersonType +++ b/share/html/Search/Elements/SelectPersonType @@ -83,10 +83,18 @@ <%INIT> my ( @types, @subtypes ); +my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'}); + if ( $Class eq 'RT::Assets' ) { @types = qw(Owner HeldBy Contact); @subtypes = @{ $RT::Assets::SEARCHABLE_SUBFIELDS{'User'} }; + foreach my $id (keys %Catalogs) { + my $catalog = RT::Catalog->new($session{'CurrentUser'}); + $catalog->Load($id); + next unless $catalog->Id; + $CustomRoles->LimitToObjectId($catalog->Id); + } } else { if ($Role) { @@ -106,23 +114,26 @@ else { else { @types = qw(Requestor Cc AdminCc Watcher Owner QueueCc QueueAdminCc QueueWatcher); - my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'}); foreach my $id (keys %Queues) { my $queue = RT::Queue->new($session{'CurrentUser'}); $queue->Load($id); next unless $queue->Id; $CustomRoles->LimitToObjectId($queue->Id); } - $m->callback( - CallbackName => 'MassageCustomRoles', - CustomRoles => $CustomRoles, - ); - push @types, map { [ "CustomRole.{" . $_->Name . "}", $_->Name ] } @{ $CustomRoles->ItemsArrayRef }; } @subtypes = @{ $RT::Tickets::SEARCHABLE_SUBFIELDS{'User'} }; } +# If there are no referenced queues/catalogs, do not limit LookupType to return 0 custom roles. +$CustomRoles->LimitToLookupType( $Class->RecordClass->CustomFieldLookupType ) if $CustomRoles->_isLimited; + +$m->callback( + CallbackName => 'MassageCustomRoles', + CustomRoles => $CustomRoles, +); +push @types, map { [ "CustomRole.{" . $_->Name . "}", $_->Name ] } @{ $CustomRoles->ItemsArrayRef }; + $m->callback(Types => \@types, Subtypes => \@subtypes); </%INIT> @@ -136,4 +147,5 @@ $Name => 'WatcherType' $Role => undef @Roles => () %Queues => () +%Catalogs => () </%ARGS> 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@example.com' ); +my $blake = RT::Test->load_or_create_user( EmailAddress => 'blake@example.com' ); +my $williamson = RT::Test->load_or_create_user( EmailAddress => 'williamson@example.com' ); +my $moss = RT::Test->load_or_create_user( EmailAddress => 'moss@example.com' ); +my $ricky = RT::Test->load_or_create_user( EmailAddress => 'ricky.roma@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..0d236aaf06 --- /dev/null +++ b/t/customroles/web-assets.t @@ -0,0 +1,279 @@ +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@example.com'); +my $licensee = RT::Test->load_or_create_user(Name => 'licensee@example.com', EmailAddress => 'licensee@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, CatalogChanged => 1 } }, "Picked a catalog"); + $m->text_contains('Licensee', 'custom role name'); + $m->content_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@example.com', + $role->GroupType => 'licensee@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@example.com', "Owner matches"; + is $asset->RoleAddresses($role->GroupType), 'licensee@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, CatalogChanged => 1 } }, "Picked a catalog"); + $m->text_contains('Licensee', 'custom role name'); + $m->content_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@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@example.com', "Owner matches"; + is $asset2->RoleAddresses($role->GroupType), '', "No Licensee"; +} + +diag "Search by custom role"; +{ + $m->follow_link_ok({ id => "assets-simple_search" }, "Asset simple search link"); + $m->submit_form_ok({ with_fields => { Catalog => $catalog->Id } }, "Picked a catalog"); + $m->submit_form_ok({ + with_fields => { + 'Role.' . $role->GroupType => 'licensee@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@example.com', + }, + button => 'SearchAssets', + }, "Search by role"); + + $m->text_lacks('Some Software', 'search miss'); + $m->text_contains('More Software', 'search hit'); +} + +diag "Search by custom role"; +{ + $m->follow_link_ok({ id => "assets-search" }, "Asset search link"); + $m->submit_form_ok({ with_fields => { ValueOfCatalog => $catalog->Id }, button => 'AddClause' }, "Picked a catalog"); + + my $form = $m->form_name('BuildQuery'); + my @watcher_options = ( '', qw/Owner HeldBy Contact CustomRole.{Licensee}/ ); + is_deeply( [ $form->find_input('WatcherField')->possible_values ], \@watcher_options, 'WatcherField options' ); + + $m->submit_form_ok({ + with_fields => { + WatcherField => 'CustomRole.{Licensee}', + ValueOfWatcher => 'licensee@example.com', + }, + button => 'DoSearch', + }, "Search by role"); + + $m->text_contains('Some Software', 'search hit'); + $m->text_lacks('More Software', 'search miss'); + + $m->follow_link_ok({ id => "assets-search" }, "Asset search link"); + $m->submit_form_ok({ with_fields => { ValueOfCatalog => $catalog->Id }, button => 'AddClause' }, "Picked a catalog"); + $m->submit_form_ok({ + with_fields => { + WatcherField => 'CustomRole.{Licensee}', + ValueOfWatcher => 'licensee@example.com', + WatcherOp => 'NOT LIKE', + }, + button => 'DoSearch', + }, "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@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@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, CatalogChanged => 1 } }, "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@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@example.com', "Owner matches"; + is $asset3->RoleAddresses($role->GroupType), '', "No Licensee"; +} + +done_testing; |