From 73b1fb96a09835dac2a36826f251566cef2eef18 Mon Sep 17 00:00:00 2001 From: sunnavy Date: Fri, 1 Dec 2023 13:49:49 -0500 Subject: [PATCH 1/7] Abstract procedures to get queue-specific custom fields and roles With this abstraction, we will be able to add corresponding support to asset charts easily, where we need to get catalog-specific custom fields and roles. --- lib/RT/Report.pm | 141 +++++++++++++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 59 deletions(-) diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm index 916ae47a0c8..3db792e31f5 100644 --- a/lib/RT/Report.pm +++ b/lib/RT/Report.pm @@ -124,27 +124,7 @@ our %GROUPINGS_META = ( my @res; if ( $args->{key} =~ /^CustomRole/ ) { - my $queues = $args->{'Queues'}; - if ( !$queues && $args->{'Query'} ) { - require RT::Interface::Web::QueryBuilder::Tree; - my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND'); - $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self ); - $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser ); - } - return () unless $queues; - - my $crs = RT::CustomRoles->new( $self->CurrentUser ); - $crs->LimitToLookupType( $self->RecordClass->CustomFieldLookupType ); - # Adding this to avoid returning all records when no queues are available. - $crs->LimitToObjectId(0); - - for my $id ( keys %$queues ) { - my $queue = RT::Queue->new( $self->CurrentUser ); - $queue->Load($id); - next unless $queue->id; - - $crs->LimitToObjectId( $queue->id ); - } + my $crs = $self->GetCustomRoles(%$args); while ( my $cr = $crs->Next ) { for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) { push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field"; @@ -256,28 +236,8 @@ our %GROUPINGS_META = ( my $self = shift; my $args = shift; - - my $queues = $args->{'Queues'}; - if ( !$queues && $args->{'Query'} ) { - require RT::Interface::Web::QueryBuilder::Tree; - my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND'); - $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self ); - $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser ); - } - return () unless $queues; - my @res; - - my $CustomFields = RT::CustomFields->new( $self->CurrentUser ); - $CustomFields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType ); - $CustomFields->LimitToObjectId(0); - foreach my $id (keys %$queues) { - my $queue = RT::Queue->new( $self->CurrentUser ); - $queue->Load($id); - next unless $queue->id; - $CustomFields->SetContextObject( $queue ) if keys %$queues == 1; - $CustomFields->LimitToObjectId($queue->id); - } + my $CustomFields = $self->GetCustomFields(%$args); while ( my $CustomField = $CustomFields->Next ) { push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}"; } @@ -1336,23 +1296,7 @@ sub _SetupCustomDateRanges { sub _NumericCustomFields { my $self = shift; my %args = @_; - my $custom_fields = RT::CustomFields->new( $self->CurrentUser ); - $custom_fields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType ); - $custom_fields->LimitToObjectId(0); - - if ( $args{'Query'} ) { - require RT::Interface::Web::QueryBuilder::Tree; - my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND'); - $tree->ParseSQL( Query => $args{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self ); - my $queues = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser ); - foreach my $id ( keys %$queues ) { - my $queue = RT::Queue->new( $self->CurrentUser ); - $queue->Load($id); - next unless $queue->id; - $custom_fields->SetContextObject($queue) if keys %$queues == 1; - $custom_fields->LimitToObjectId( $queue->id ); - } - } + my $custom_fields = $self->GetCustomFields(%args); my @items; while ( my $custom_field = $custom_fields->Next ) { @@ -1454,6 +1398,85 @@ sub _SingularClass { return (ref $self || $self) . '::Entry'; } +=head2 GetReferencedObjects Query => QUERY + +This is generally an abstraction of GetReferenced... methods in +L, based on what current report is for. + +Returns a tuple of the class and referenced objects. + +=cut + +sub GetReferencedObjects { + my $self = shift; + my %args = @_; + + my $class = 'RT::Queue'; + my $method = 'GetReferencedQueues'; + + my $objects; + if ( $args{Query} ) { + require RT::Interface::Web::QueryBuilder::Tree; + my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND'); + $tree->ParseSQL( Query => $args{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self ); + $objects = $tree->$method( CurrentUser => $self->CurrentUser ); + } + return ( $class, $objects ); +} + +=head2 GetCustomFields Query => QUERY + +Returns an L object that contains all possible custom +fields the given query can refer to. + +=cut + +sub GetCustomFields { + my $self = shift; + my %args = @_; + + my $custom_fields = RT::CustomFields->new( $self->CurrentUser ); + $custom_fields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType ); + $custom_fields->LimitToObjectId(0); + + if ( $args{'Query'} ) { + my ( $referenced_class, $referenced_objects ) = $self->GetReferencedObjects(%args); + foreach my $id ( keys %{$referenced_objects} ) { + my $object = $referenced_class->new( $self->CurrentUser ); + $object->Load($id); + next unless $object->id; + $custom_fields->SetContextObject($object) if keys %{$referenced_objects} == 1; + $custom_fields->LimitToObjectId( $object->id ); + } + } + return $custom_fields; +} + +=head2 GetCustomRoles Query => QUERY + +Returns an L object that contains all possible custom +roles the given query can refer to. + +=cut + +sub GetCustomRoles { + my $self = shift; + my %args = @_; + + my $custom_roles = RT::CustomRoles->new( $self->CurrentUser ); + $custom_roles->LimitToLookupType( $self->RecordClass->CustomFieldLookupType ); + # Adding this to avoid returning all records when no queues are available. + $custom_roles->LimitToObjectId(0); + + my ( $referenced_class, $referenced_objects ) = $self->GetReferencedObjects(%args); + foreach my $id ( keys %{$referenced_objects} ) { + my $object = $referenced_class->new( $self->CurrentUser ); + $object->Load($id); + next unless $object->id; + $custom_roles->LimitToObjectId( $object->id ); + } + return $custom_roles; +} RT::Base->_ImportOverlays(); From 66fb05d0dd87f064b348b2848ae59362b2a4fd9e Mon Sep 17 00:00:00 2001 From: sunnavy Date: Tue, 5 Dec 2023 11:25:52 -0500 Subject: [PATCH 2/7] Move a few more general code from RT::Report::Tickets to RT::Report We are going to reuse them in the upcoming asset charts. --- lib/RT/Report.pm | 398 ++++++++++++++++++++++++++++++++++ lib/RT/Report/Tickets.pm | 385 +------------------------------- lib/RT/Report/Transactions.pm | 2 +- 3 files changed, 400 insertions(+), 385 deletions(-) diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm index 3db792e31f5..52b1b1c602d 100644 --- a/lib/RT/Report.pm +++ b/lib/RT/Report.pm @@ -559,6 +559,71 @@ sub SetupGroupings { @_ ); + $self->FromSQL( $args{'Query'} ) if $args{'Query'}; + + # Apply ACL checks + $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks'); + + # See if our query is distinct + if (not $self->{'joins_are_distinct'} and $self->_isJoined) { + # If it isn't, we need to do this in two stages -- first, find + # the distinct matching tickets (with no group by), then search + # within the matching tickets grouped by what is wanted. + $self->Columns( 'id' ); + if ( RT->Config->Get('UseSQLForACLChecks') ) { + my $query = $self->BuildSelectQuery( PreferBind => 0 ); + $self->CleanSlate; + $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 ); + } + else { + # ACL is done in Next call + my @match = (0); + while ( my $row = $self->Next ) { + push @match, $row->id; + } + + # Replace the query with one that matches precisely those + # tickets, with no joins. We then mark it as having been ACL'd, + # since it was by dint of being in the search results above + $self->CleanSlate; + while ( @match > 1000 ) { + my @batch = splice( @match, 0, 1000 ); + $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch ); + } + $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match ); + } + $self->{'_sql_current_user_can_see_applied'} = 1 + } + + my %res = $self->_SetupGroupings(%args); + + if ($args{Query} + && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } ) + || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ } + values %{ $self->{column_info} } ) + || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} } + values %{ $self->{column_info} } ) ) + ) + { + # Need to do the groupby/calculation at Perl level + $self->{_query} = $args{'Query'}; + } + else { + delete $self->{_query}; + } + + return %res; +} + +sub _SetupGroupings { + my $self = shift; + my %args = ( + Query => undef, + GroupBy => undef, + Function => undef, + @_ + ); + my $i = 0; my @group_by = grep defined && length, @@ -1366,6 +1431,324 @@ sub DefaultGroupBy { # The following methods are more collection related +=head2 _DoSearchInPerl + +For complicated reports that can't be calculated in SQL, do them in Perl. + +=cut + +sub _DoSearchInPerl { + my $self = shift; + + my $objects = $self->_CollectionClass->new( $self->CurrentUser ); + $objects->FromSQL( $self->{_query} ); + my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList; + my %info; + + my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) } + keys %{ RT->Config->Get('ServiceBusinessHours') || {} }; + + while ( my $object = $objects->Next ) { + my $bh = $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : ''; + + my @keys; + my @extra_keys; + my %css_class; + for my $group ( @groups ) { + my $value; + + if ( $object->_Accessible($group->{KEY}, 'read' )) { + if ( $group->{SUBKEY} ) { + my $method = "$group->{KEY}Obj"; + if ( my $obj = $object->$method ) { + if ( $group->{INFO} eq 'Date' ) { + if ( $obj->Unix > 0 ) { + $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} }, + Timezone => 'user' ); + } + else { + $value = $self->loc('(no value)') + } + } + else { + $value = $obj->_Value($group->{SUBKEY}); + } + $value //= $self->loc('(no value)'); + } + } + $value //= $object->_Value( $group->{KEY} ) // $self->loc('(no value)'); + } + elsif ( $group->{INFO} eq 'Watcher' ) { + my @values; + if ( $object->can($group->{KEY}) ) { + my $method = $group->{KEY}; + push @values, map { $_->MemberId } @{$object->$method->MembersObj->ItemsArrayRef}; + } + elsif ( $group->{KEY} eq 'Watcher' ) { + push @values, map { $_->MemberId } @{$object->$_->MembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/; + } + else { + RT->Logger->error("Unsupported group by $group->{KEY}"); + next; + } + + @values = $self->loc('(no value)') unless @values; + $value = \@values; + } + elsif ( $group->{INFO} eq 'CustomField' ) { + my ($id) = $group->{SUBKEY} =~ /{(\d+)}/; + my $values = $object->CustomFieldValues($id); + if ( $values->Count ) { + $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ]; + } + else { + $value = $self->loc('(no value)'); + } + } + elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) { + my $business_time = $1; + + if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) { + my $start = $1; + my $end = $2; + my $start_method = $start . 'Obj'; + my $end_method = $end . 'Obj'; + if ( $object->$end_method->Unix > 0 && $object->$start_method->Unix > 0 ) { + my $seconds; + + if ($business_time) { + $seconds = $object->CustomDateRange( + '', + { value => "$end - $start", + business_time => 1, + format => sub { $_[0] }, + } + ); + } + else { + $seconds = $object->$end_method->Unix - $object->$start_method->Unix; + } + + if ( $group->{SUBKEY} eq 'Default' ) { + $value = RT::Date->new( $self->CurrentUser )->DurationAsString( + $seconds, + Show => $group->{META}{Show}, + Short => $group->{META}{Short}, + MaxUnit => $business_time ? 'hour' : 'year', + ); + } + else { + $value = RT::Date->new( $self->CurrentUser )->DurationAsString( + $seconds, + Show => $group->{META}{Show} // 3, + Short => $group->{META}{Short} // 1, + MaxUnit => lc $group->{SUBKEY}, + MinUnit => lc $group->{SUBKEY}, + Unit => lc $group->{SUBKEY}, + ); + } + } + + if ( $business_time ) { + push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none'; + } + } + else { + my %ranges = $self->_RoleGroupClass->CustomDateRanges; + if ( my $spec = $ranges{$group->{FIELD}} ) { + if ( $group->{SUBKEY} eq 'Default' ) { + $value = $object->CustomDateRange( $group->{FIELD}, $spec ); + } + else { + my $seconds = $object->CustomDateRange( $group->{FIELD}, + { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } ); + + if ( defined $seconds ) { + $value = RT::Date->new( $self->CurrentUser )->DurationAsString( + $seconds, + Show => $group->{META}{Show} // 3, + Short => $group->{META}{Short} // 1, + MaxUnit => lc $group->{SUBKEY}, + MinUnit => lc $group->{SUBKEY}, + Unit => lc $group->{SUBKEY}, + ); + } + } + if ( ref $spec && $spec->{business_time} ) { + # 1 means the corresponding one in SLA, which $bh already holds + $bh = $spec->{business_time} unless $spec->{business_time} eq '1'; + push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none'; + } + } + } + + $value //= $self->loc('(no value)'); + } + else { + RT->Logger->error("Unsupported group by $group->{KEY}"); + next; + } + push @keys, $value; + } + push @keys, @extra_keys; + + # @keys could contain arrayrefs, so we need to expand it. + # e.g. "open", [ "root", "foo" ], "General" ) + # will be expanded to: + # "open", "root", "General" + # "open", "foo", "General" + + my @all_keys; + for my $key (@keys) { + if ( ref $key eq 'ARRAY' ) { + if (@all_keys) { + my @new_all_keys; + for my $keys ( @all_keys ) { + push @new_all_keys, [ @$keys, $_ ] for @$key; + } + @all_keys = @new_all_keys; + } + else { + push @all_keys, [$_] for @$key; + } + } + else { + if (@all_keys) { + @all_keys = map { [ @$_, $key ] } @all_keys; + } + else { + push @all_keys, [$key]; + } + } + } + + my @fields = grep { $_->{TYPE} eq 'statistic' } + map { $self->ColumnInfo($_) } $self->ColumnsList; + + while ( my $field = shift @fields ) { + for my $keys (@all_keys) { + my $key = join ';;;', @$keys; + if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) { + $info{$key}{ $field->{NAME} }++; + } + elsif ( $field->{NAME} =~ /^postfunction/ ) { + if ( $field->{MAP} ) { + my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/; + for my $item ( values %{ $field->{MAP} } ) { + push @fields, + { + NAME => $item->{NAME}, + FIELD => $item->{FIELD}, + INFO => [ + '', $meta_type, + $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '', + @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ], + ], + }; + } + } + } + elsif ( $field->{INFO}[1] eq 'Time' ) { + if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) { + my $method = $1; + my $type = $field->{INFO}[2]; + my $name = lc $field->{NAME}; + + $info{$key}{$name} + = $self->_CalculateTime( $type, $object->$method * 60, $info{$key}{$name} ) || 0; + } + else { + RT->Logger->error("Unsupported field $field->{NAME}"); + } + } + elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) { + my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} }; + my $name = lc $field->{NAME}; + $info{$key}{$name} ||= 0; + + my $start_method = $start . 'Obj'; + my $end_method = $end . 'Obj'; + next unless $object->$end_method->Unix > 0 && $object->$start_method->Unix > 0; + + my $value; + if ($extra_info->{business_time}) { + $value = $object->CustomDateRange( + '', + { value => "$end - $start", + business_time => $extra_info->{business_time}, + format => sub { return $_[0] }, + } + ); + } + else { + $value = $object->$end_method->Unix - $object->$start_method->Unix; + } + + $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} ); + } + elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) { + my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} }; + my $name = lc $field->{NAME}; + $info{$key}{$name} ||= 0; + + my $value; + my %ranges = $self->_RoleGroupClass->CustomDateRanges; + if ( my $spec = $ranges{$range_name} ) { + $value = $object->CustomDateRange( + $range_name, + { + ref $spec eq 'HASH' ? %$spec : ( value => $spec ), + format => sub { $_[0] }, + } + ); + } + $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} ); + } + else { + RT->Logger->error("Unsupported field $field->{INFO}[1]"); + } + } + } + + for my $keys (@all_keys) { + my $key = join ';;;', @$keys; + push @{ $info{$key}{ids} }, $object->id; + } + } + + # Make generated results real SB results + for my $key ( keys %info ) { + my @keys = split /;;;/, $key; + my $row; + for my $group ( @groups ) { + $row->{lc $group->{NAME}} = shift @keys; + } + for my $field ( keys %{ $info{$key} } ) { + my $value = $info{$key}{$field}; + if ( ref $value eq 'HASH' && $value->{calculate} ) { + $row->{$field} = $value->{calculate}->($value); + } + else { + $row->{$field} = $info{$key}{$field}; + } + } + my $item = $self->NewItem(); + + # Has extra css info + for my $key (@keys) { + if ( $key =~ /(.+) => (.+)/ ) { + $row->{_css_class}{$1} = $2; + } + } + + $item->LoadFromHash($row); + $self->AddRecord($item); + } + $self->{must_redo_search} = 0; + $self->{is_limited} = 1; + $self->PostProcessRecords; +} + sub _PostSearch { my $self = shift; if ( $self->{'must_redo_search'} ) { @@ -1378,6 +1761,14 @@ sub _PostSearch { } } + +# Gotta skip over customized Next, since it does all sorts of crazy magic we don't want. +sub Next { + my $self = shift; + $self->RT::SearchBuilder::Next(@_); + +} + sub NewItem { my $self = shift; my $res = $self->_SingularClass->new($self->CurrentUser); @@ -1478,6 +1869,13 @@ sub GetCustomRoles { return $custom_roles; } +sub _CollectionClass { + my $self = shift; + my $class = ref $self || $self; + $class =~ s!::Report!!; + return $class; +} + RT::Base->_ImportOverlays(); 1; diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm index 53d9424b55a..bc3611aceca 100644 --- a/lib/RT/Report/Tickets.pm +++ b/lib/RT/Report/Tickets.pm @@ -210,71 +210,6 @@ foreach my $pair ( } } -sub SetupGroupings { - my $self = shift; - my %args = ( - Query => undef, - GroupBy => undef, - Function => undef, - @_ - ); - - $self->FromSQL( $args{'Query'} ) if $args{'Query'}; - - # Apply ACL checks - $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks'); - - # See if our query is distinct - if (not $self->{'joins_are_distinct'} and $self->_isJoined) { - # If it isn't, we need to do this in two stages -- first, find - # the distinct matching tickets (with no group by), then search - # within the matching tickets grouped by what is wanted. - $self->Columns( 'id' ); - if ( RT->Config->Get('UseSQLForACLChecks') ) { - my $query = $self->BuildSelectQuery( PreferBind => 0 ); - $self->CleanSlate; - $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 ); - } - else { - # ACL is done in Next call - my @match = (0); - while ( my $row = $self->Next ) { - push @match, $row->id; - } - - # Replace the query with one that matches precisely those - # tickets, with no joins. We then mark it as having been ACL'd, - # since it was by dint of being in the search results above - $self->CleanSlate; - while ( @match > 1000 ) { - my @batch = splice( @match, 0, 1000 ); - $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch ); - } - $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match ); - } - $self->{'_sql_current_user_can_see_applied'} = 1 - } - - my %res = $self->SUPER::SetupGroupings(%args); - - if ($args{Query} - && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } ) - || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ } - values %{ $self->{column_info} } ) - || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} } - values %{ $self->{column_info} } ) ) - ) - { - # Need to do the groupby/calculation at Perl level - $self->{_query} = $args{'Query'}; - } - else { - delete $self->{_query}; - } - - return %res; -} - =head2 _DoSearch Subclass _DoSearch from our parent so we can go through and add in empty @@ -290,330 +225,12 @@ sub _DoSearch { my $self = shift; # When groupby/calculation can't be done at SQL level, do it at Perl level - if ( $self->{_query} ) { - my $tickets = RT::Tickets->new( $self->CurrentUser ); - $tickets->FromSQL( $self->{_query} ); - my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList; - my %info; - - my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) } - keys %{ RT->Config->Get('ServiceBusinessHours') || {} }; - - while ( my $ticket = $tickets->Next ) { - my $bh = $ticket->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $ticket->SLA }{BusinessHours} : ''; - - my @keys; - my @extra_keys; - my %css_class; - for my $group ( @groups ) { - my $value; - - if ( $ticket->_Accessible($group->{KEY}, 'read' )) { - if ( $group->{SUBKEY} ) { - my $method = "$group->{KEY}Obj"; - if ( my $obj = $ticket->$method ) { - if ( $group->{INFO} eq 'Date' ) { - if ( $obj->Unix > 0 ) { - $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} }, - Timezone => 'user' ); - } - else { - $value = $self->loc('(no value)') - } - } - else { - $value = $obj->_Value($group->{SUBKEY}); - } - $value //= $self->loc('(no value)'); - } - } - $value //= $ticket->_Value( $group->{KEY} ) // $self->loc('(no value)'); - } - elsif ( $group->{INFO} eq 'Watcher' ) { - my @values; - if ( $ticket->can($group->{KEY}) ) { - my $method = $group->{KEY}; - push @values, map { $_->MemberId } @{$ticket->$method->MembersObj->ItemsArrayRef}; - } - elsif ( $group->{KEY} eq 'Watcher' ) { - push @values, map { $_->MemberId } @{$ticket->$_->MembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/; - } - else { - RT->Logger->error("Unsupported group by $group->{KEY}"); - next; - } - - @values = $self->loc('(no value)') unless @values; - $value = \@values; - } - elsif ( $group->{INFO} eq 'CustomField' ) { - my ($id) = $group->{SUBKEY} =~ /{(\d+)}/; - my $values = $ticket->CustomFieldValues($id); - if ( $values->Count ) { - $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ]; - } - else { - $value = $self->loc('(no value)'); - } - } - elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) { - my $business_time = $1; - - if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) { - my $start = $1; - my $end = $2; - my $start_method = $start . 'Obj'; - my $end_method = $end . 'Obj'; - if ( $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0 ) { - my $seconds; - - if ($business_time) { - $seconds = $ticket->CustomDateRange( - '', - { value => "$end - $start", - business_time => 1, - format => sub { $_[0] }, - } - ); - } - else { - $seconds = $ticket->$end_method->Unix - $ticket->$start_method->Unix; - } - - if ( $group->{SUBKEY} eq 'Default' ) { - $value = RT::Date->new( $self->CurrentUser )->DurationAsString( - $seconds, - Show => $group->{META}{Show}, - Short => $group->{META}{Short}, - MaxUnit => $business_time ? 'hour' : 'year', - ); - } - else { - $value = RT::Date->new( $self->CurrentUser )->DurationAsString( - $seconds, - Show => $group->{META}{Show} // 3, - Short => $group->{META}{Short} // 1, - MaxUnit => lc $group->{SUBKEY}, - MinUnit => lc $group->{SUBKEY}, - Unit => lc $group->{SUBKEY}, - ); - } - } - - if ( $business_time ) { - push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none'; - } - } - else { - my %ranges = RT::Ticket->CustomDateRanges; - if ( my $spec = $ranges{$group->{FIELD}} ) { - if ( $group->{SUBKEY} eq 'Default' ) { - $value = $ticket->CustomDateRange( $group->{FIELD}, $spec ); - } - else { - my $seconds = $ticket->CustomDateRange( $group->{FIELD}, - { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } ); - - if ( defined $seconds ) { - $value = RT::Date->new( $self->CurrentUser )->DurationAsString( - $seconds, - Show => $group->{META}{Show} // 3, - Short => $group->{META}{Short} // 1, - MaxUnit => lc $group->{SUBKEY}, - MinUnit => lc $group->{SUBKEY}, - Unit => lc $group->{SUBKEY}, - ); - } - } - if ( ref $spec && $spec->{business_time} ) { - # 1 means the corresponding one in SLA, which $bh already holds - $bh = $spec->{business_time} unless $spec->{business_time} eq '1'; - push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none'; - } - } - } - - $value //= $self->loc('(no value)'); - } - else { - RT->Logger->error("Unsupported group by $group->{KEY}"); - next; - } - push @keys, $value; - } - push @keys, @extra_keys; - - # @keys could contain arrayrefs, so we need to expand it. - # e.g. "open", [ "root", "foo" ], "General" ) - # will be expanded to: - # "open", "root", "General" - # "open", "foo", "General" - - my @all_keys; - for my $key (@keys) { - if ( ref $key eq 'ARRAY' ) { - if (@all_keys) { - my @new_all_keys; - for my $keys ( @all_keys ) { - push @new_all_keys, [ @$keys, $_ ] for @$key; - } - @all_keys = @new_all_keys; - } - else { - push @all_keys, [$_] for @$key; - } - } - else { - if (@all_keys) { - @all_keys = map { [ @$_, $key ] } @all_keys; - } - else { - push @all_keys, [$key]; - } - } - } - - my @fields = grep { $_->{TYPE} eq 'statistic' } - map { $self->ColumnInfo($_) } $self->ColumnsList; - - while ( my $field = shift @fields ) { - for my $keys (@all_keys) { - my $key = join ';;;', @$keys; - if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) { - $info{$key}{ $field->{NAME} }++; - } - elsif ( $field->{NAME} =~ /^postfunction/ ) { - if ( $field->{MAP} ) { - my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/; - for my $item ( values %{ $field->{MAP} } ) { - push @fields, - { - NAME => $item->{NAME}, - FIELD => $item->{FIELD}, - INFO => [ - '', $meta_type, - $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '', - @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ], - ], - }; - } - } - } - elsif ( $field->{INFO}[1] eq 'Time' ) { - if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) { - my $method = $1; - my $type = $field->{INFO}[2]; - my $name = lc $field->{NAME}; - - $info{$key}{$name} - = $self->_CalculateTime( $type, $ticket->$method * 60, $info{$key}{$name} ) || 0; - } - else { - RT->Logger->error("Unsupported field $field->{NAME}"); - } - } - elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) { - my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} }; - my $name = lc $field->{NAME}; - $info{$key}{$name} ||= 0; - - my $start_method = $start . 'Obj'; - my $end_method = $end . 'Obj'; - next unless $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0; - - my $value; - if ($extra_info->{business_time}) { - $value = $ticket->CustomDateRange( - '', - { value => "$end - $start", - business_time => $extra_info->{business_time}, - format => sub { return $_[0] }, - } - ); - } - else { - $value = $ticket->$end_method->Unix - $ticket->$start_method->Unix; - } - - $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} ); - } - elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) { - my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} }; - my $name = lc $field->{NAME}; - $info{$key}{$name} ||= 0; - - my $value; - my %ranges = RT::Ticket->CustomDateRanges; - if ( my $spec = $ranges{$range_name} ) { - $value = $ticket->CustomDateRange( - $range_name, - { - ref $spec eq 'HASH' ? %$spec : ( value => $spec ), - format => sub { $_[0] }, - } - ); - } - $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} ); - } - else { - RT->Logger->error("Unsupported field $field->{INFO}[1]"); - } - } - } - - for my $keys (@all_keys) { - my $key = join ';;;', @$keys; - push @{ $info{$key}{ids} }, $ticket->id; - } - } - - # Make generated results real SB results - for my $key ( keys %info ) { - my @keys = split /;;;/, $key; - my $row; - for my $group ( @groups ) { - $row->{lc $group->{NAME}} = shift @keys; - } - for my $field ( keys %{ $info{$key} } ) { - my $value = $info{$key}{$field}; - if ( ref $value eq 'HASH' && $value->{calculate} ) { - $row->{$field} = $value->{calculate}->($value); - } - else { - $row->{$field} = $info{$key}{$field}; - } - } - my $item = $self->NewItem(); - - # Has extra css info - for my $key (@keys) { - if ( $key =~ /(.+) => (.+)/ ) { - $row->{_css_class}{$1} = $2; - } - } - - $item->LoadFromHash($row); - $self->AddRecord($item); - } - $self->{must_redo_search} = 0; - $self->{is_limited} = 1; - $self->PostProcessRecords; - - return; - } + return $self->_DoSearchInPerl(@_) if $self->{_query}; $self->SUPER::_DoSearch( @_ ); $self->_PostSearch(); } -# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we -# don't want. -sub Next { - my $self = shift; - $self->RT::SearchBuilder::Next(@_); - -} - sub new { my $self = shift; $self->_SetupCustomDateRanges; diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm index 3d285befc25..64557057a48 100644 --- a/lib/RT/Report/Transactions.pm +++ b/lib/RT/Report/Transactions.pm @@ -115,7 +115,7 @@ sub SetupGroupings { $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match ); } - return $self->SUPER::SetupGroupings(%args); + return $self->_SetupGroupings(%args); } sub _DoSearch { From 7d4d396ca90577283e2ba3391076e6f2980796bc Mon Sep 17 00:00:00 2001 From: sunnavy Date: Fri, 1 Dec 2023 11:58:08 -0500 Subject: [PATCH 3/7] Add asset charts support Here we also add Creator/LastUpdatedBy/Watcher search support to make links on charts work. E.g. if you group by Watcher, the AssetSQL on bars will be like: ... AND (Watcher.Name = 'root') --- lib/RT/Assets.pm | 8 +- lib/RT/Interface/Web/MenuBuilder.pm | 1 + lib/RT/Report.pm | 35 ++++++- lib/RT/Report/Assets.pm | 137 ++++++++++++++++++++++++++++ lib/RT/Report/Assets/Entry.pm | 58 ++++++++++++ 5 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 lib/RT/Report/Assets.pm create mode 100644 lib/RT/Report/Assets/Entry.pm diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm index 516869f9ccb..30ab53629ca 100644 --- a/lib/RT/Assets.pm +++ b/lib/RT/Assets.pm @@ -71,6 +71,8 @@ our %FIELD_METADATA = ( Catalog => [ 'ENUM' => 'Catalog', ], #loc_left_pair LastUpdated => [ 'DATE' => 'LastUpdated', ], #loc_left_pair Created => [ 'DATE' => 'Created', ], #loc_left_pair + Creator => [ 'ENUM' => 'User', ], #loc_left_pair + LastUpdatedBy => [ 'ENUM' => 'User', ], #loc_left_pair Linked => [ 'LINK' ], #loc_left_pair LinkedTo => [ 'LINK' => 'To' ], #loc_left_pair @@ -90,6 +92,7 @@ our %FIELD_METADATA = ( Contact => [ 'WATCHERFIELD' => 'Contact', ], #loc_left_pair ContactGroup => [ 'MEMBERSHIPFIELD' => 'Contact', ], #loc_left_pair CustomRole => [ 'WATCHERFIELD' ], # loc_left_pair + Watcher => [ 'WATCHERFIELD', ], #loc_left_pair CustomFieldValue => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair CustomField => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair @@ -396,7 +399,10 @@ sub AddRecord { my $asset = shift; return unless $asset->CurrentUserCanSee; - return if $asset->__Value('Status') eq 'deleted' + # No need to check "deleted" if it's from AssetSQL(_sql_query is set). This + # also short circuits Status check for RT::Report::Assets::Entry, which + # doesn't have Status column + return if !$self->{_sql_query} and $asset->__Value('Status') eq 'deleted' and not $self->{'allow_deleted_search'}; $self->SUPER::AddRecord($asset, @_); diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm index 82f67b0018c..6964b7b3157 100644 --- a/lib/RT/Interface/Web/MenuBuilder.pm +++ b/lib/RT/Interface/Web/MenuBuilder.pm @@ -759,6 +759,7 @@ sub BuildMainNav { } elsif ( $class eq 'RT::Assets' ) { $current_search_menu->child( bulk => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" ); + $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" ); } elsif ( $class eq 'RT::Transactions' ) { $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" ); diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm index 52b1b1c602d..81f9b74c438 100644 --- a/lib/RT/Report.pm +++ b/lib/RT/Report.pm @@ -97,6 +97,18 @@ our %GROUPINGS_META = ( Localize => 1, Distinct => 1, }, + Catalog => { + Display => sub { + my $self = shift; + my %args = (@_); + + my $catalog = RT::Catalog->new( $self->CurrentUser ); + $catalog->Load( $args{'VALUE'} ); + return $catalog->Name; + }, + Localize => 1, + Distinct => 1, + }, Priority => { Sort => 'numeric raw', Distinct => 1, @@ -1445,11 +1457,17 @@ sub _DoSearchInPerl { my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList; my %info; - my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) } - keys %{ RT->Config->Get('ServiceBusinessHours') || {} }; + my %bh_class; + + # Can't use ->can('SLA') as SLA is an autoloaded method of RT::Ticket + if ( $self->_SingularClass->ObjectType->_ClassAccessible->{SLA} ) { + %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) } + keys %{ RT->Config->Get('ServiceBusinessHours') || {} }; + } while ( my $object = $objects->Next ) { - my $bh = $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : ''; + my $bh = %bh_class + && $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : ''; my @keys; my @extra_keys; @@ -1802,8 +1820,15 @@ sub GetReferencedObjects { my $self = shift; my %args = @_; - my $class = 'RT::Queue'; - my $method = 'GetReferencedQueues'; + my ( $class, $method ); + if ( $self->isa('RT::Report::Assets') ) { + $class = 'RT::Catalog'; + $method = 'GetReferencedCatalogs'; + } + else { + $class = 'RT::Queue'; + $method = 'GetReferencedQueues'; + } my $objects; if ( $args{Query} ) { diff --git a/lib/RT/Report/Assets.pm b/lib/RT/Report/Assets.pm new file mode 100644 index 00000000000..a1590c3526a --- /dev/null +++ b/lib/RT/Report/Assets.pm @@ -0,0 +1,137 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2023 Best Practical Solutions, LLC +# +# +# (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::Report::Assets; + +use base qw/RT::Report RT::Assets/; +use RT::Report::Assets::Entry; + +use strict; +use warnings; +use 5.010; + +=head1 NAME + +RT::Report::Assets - Asset search charts + +=head1 DESCRIPTION + +This is the backend class for asset search charts. + +=cut + +our @GROUPINGS = ( + Status => 'Enum', #loc_left_pair + Catalog => 'Catalog', #loc_left_pair + Creator => 'User', #loc_left_pair + LastUpdatedBy => 'User', #loc_left_pair + Owner => 'Watcher', #loc_left_pair + HeldBy => 'Watcher', #loc_left_pair + Contact => 'Watcher', #loc_left_pair + Watcher => 'Watcher', #loc_left_pair + CustomRole => 'Watcher', + Created => 'Date', #loc_left_pair + LastUpdated => 'Date', #loc_left_pair + CF => 'CustomField', #loc_left_pair +); + +# loc'able strings below generated with (s/loq/loc/): +# perl -MRT=-init -MRT::Report::Assets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Assets::STATISTICS, 0, 2' +# +# loc("Asset count") +# loc("Summary of Created to LastUpdated") +# loc("Total Created to LastUpdated") +# loc("Average Created to LastUpdated") +# loc("Minimum Created to LastUpdated") +# loc("Maximum Created to LastUpdated") + +our @STATISTICS = ( + COUNT => ['Asset count', 'Count', 'id'], +); + +foreach my $pair ( + 'Created to LastUpdated', +) { + my ($from, $to) = split / to /, $pair; + push @STATISTICS, ( + "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ], + "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ], + "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ], + "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ], + "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ], + ); + push @GROUPINGS, $pair => 'Duration'; +} + +sub _DoSearch { + my $self = shift; + + # When groupby/calculation can't be done at SQL level, do it at Perl level + return $self->_DoSearchInPerl(@_) if $self->{_query}; + + $self->SUPER::_DoSearch( @_ ); + $self->_PostSearch(); +} + +sub new { + my $self = shift; + $self->_SetupCustomDateRanges; + return $self->SUPER::new(@_); +} + +sub _Init { + my $self = shift; + $self->SUPER::_Init(@_); + + # Reset OrderBy to not order by name by default + $self->OrderByCols(); +} + +RT::Base->_ImportOverlays(); + +1; diff --git a/lib/RT/Report/Assets/Entry.pm b/lib/RT/Report/Assets/Entry.pm new file mode 100644 index 00000000000..1a22edd29da --- /dev/null +++ b/lib/RT/Report/Assets/Entry.pm @@ -0,0 +1,58 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2023 Best Practical Solutions, LLC +# +# +# (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::Report::Assets::Entry; + +use warnings; +use strict; + +use base qw/RT::Report::Entry/; + +RT::Base->_ImportOverlays(); + +1; From 7dafceeeef2b5bbddd769c0409fce45533649fd0 Mon Sep 17 00:00:00 2001 From: sunnavy Date: Mon, 4 Dec 2023 14:30:28 -0500 Subject: [PATCH 4/7] Test asset charts --- t/charts/asset.t | 102 +++++++++++++++++++++++++++++++++++++ t/web/charting.t | 12 +++++ t/web/custom_frontpage.t | 31 +++++++++++ t/web/saved_search_chart.t | 30 +++++++++++ 4 files changed, 175 insertions(+) create mode 100644 t/charts/asset.t diff --git a/t/charts/asset.t b/t/charts/asset.t new file mode 100644 index 00000000000..767e53a13db --- /dev/null +++ b/t/charts/asset.t @@ -0,0 +1,102 @@ +use strict; +use warnings; + +use RT::Test::Assets tests => undef; +use RT::Report::Assets; + +for my $status (qw/new in-use in-use allocated/) { # 2 in-use assets + create_asset( Catalog => 'General assets', Name => 'test', Status => $status ); +} + +my $report = RT::Report::Assets->new( RT->SystemUser ); +my %columns = $report->SetupGroupings( + Query => q{Catalog = 'General assets'}, + GroupBy => ['Status'], + Function => ['COUNT'], +); +$report->SortEntries; + +my @colors = RT->Config->Get("ChartColors"); +my $expected = { + 'thead' => [ + { + 'cells' => [ + { + 'type' => 'head', + 'value' => 'Status' + }, + { + 'color' => $colors[0], + 'rowspan' => 1, + 'type' => 'head', + 'value' => 'Asset count' + } + ] + } + ], + 'tbody' => [ + { + 'cells' => [ + { + 'type' => 'label', + 'value' => 'allocated', + }, + { + 'query' => "(Status = 'allocated')", + 'type' => 'value', + 'value' => '1', + } + ], + 'even' => 1 + }, + { + 'even' => 0, + 'cells' => [ + { + 'type' => 'label', + 'value' => 'in-use', + }, + { + 'query' => "(Status = 'in-use')", + 'type' => 'value', + 'value' => '2', + } + ] + }, + { + 'even' => 1, + 'cells' => [ + { + 'type' => 'label', + 'value' => 'new', + }, + { + 'query' => "(Status = 'new')", + 'type' => 'value', + 'value' => '1', + } + ] + } + ], + 'tfoot' => [ + { + 'cells' => [ + { + 'colspan' => 1, + 'type' => 'label', + 'value' => 'Total' + }, + { + 'type' => 'value', + 'value' => 4 + } + ], + 'even' => 0 + } + ], +}; + +my %table = $report->FormatTable(%columns); +is_deeply( \%table, $expected, "basic table" ); + +done_testing; diff --git a/t/web/charting.t b/t/web/charting.t index 1cf81709425..a9c8454cf99 100644 --- a/t/web/charting.t +++ b/t/web/charting.t @@ -141,4 +141,16 @@ $m->get_ok("/Search/Chart?Class=RT::Transactions&Query=Type=Create"); is( $m->content_type, "image/png" ); ok( length( $m->content ), "Has content" ); +# Test asset charts +my $asset = RT::Asset->new( RT->SystemUser ); +$asset->Create( Name => 'test', Catalog => 'General assets', Status => 'new' ); +ok( $asset->Id, 'Created test asset' ); +$m->get_ok("/Search/Chart.html?Class=RT::Assets&Query=id>0"); +$m->content_like( qr{]*>Status\s*\s*]*>Asset count\s*}, "Grouped by status" ); +$m->content_like( qr{new\s*\s*]*>\s*]*>1}, "Found results in table" ); +$m->content_like( qr{get_ok("/Search/Chart?Class=RT::Assets&Query=id>0"); +is( $m->content_type, "image/png" ); +ok( length( $m->content ), "Has content" ); + done_testing; diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t index 5ed6b4964cf..a634b9f2004 100644 --- a/t/web/custom_frontpage.t +++ b/t/web/custom_frontpage.t @@ -205,12 +205,40 @@ $m->submit_form( ); $m->content_contains("Chart first txn chart saved", 'saved first txn chart' ); +# Add asset saved searches +$m->get_ok( $url . "/Search/Build.html?Class=RT::Assets&Query=" . 'id>0' ); + +$m->submit_form( + form_name => 'BuildQuery', + fields => { + SavedSearchDescription => 'first asset search', + SavedSearchOwner => 'RT::System-1', + }, + button => 'SavedSearchSave', +); +# We don't show saved message on page :/ +$m->content_contains("Save as New", 'saved first asset search' ); + +$m->get_ok( $url . "/Search/Chart.html?Class=RT::Assets&Query=" . 'id>0' ); + +$m->submit_form( + form_name => 'SaveSearch', + fields => { + SavedSearchDescription => 'first asset chart', + SavedSearchOwner => 'RT::System-1', + }, + button => 'SavedSearchSave', +); +$m->content_contains("Chart first asset chart saved", 'saved first txn chart' ); + $m->get_ok( $url . "Dashboards/Queries.html?id=$id" ); push( @{$args->{body}}, "saved-" . $m->dom->find('[data-description="first chart"]')->first->attr('data-name'), "saved-" . $m->dom->find('[data-description="first txn search"]')->first->attr('data-name'), "saved-" . $m->dom->find('[data-description="first txn chart"]')->first->attr('data-name'), + "saved-" . $m->dom->find('[data-description="first asset search"]')->first->attr('data-name'), + "saved-" . $m->dom->find('[data-description="first asset chart"]')->first->attr('data-name'), ); $res = $m->post( @@ -226,5 +254,8 @@ $m->text_contains('first chart'); $m->text_contains('first txn search'); $m->text_contains('first txn chart'); $m->text_contains('Transaction count', 'txn chart content'); +$m->text_contains('first asset search'); +$m->text_contains('first asset chart'); +$m->text_contains('Asset count', 'asset chart content'); done_testing; diff --git a/t/web/saved_search_chart.t b/t/web/saved_search_chart.t index 26366cc101c..93fb297bd2c 100644 --- a/t/web/saved_search_chart.t +++ b/t/web/saved_search_chart.t @@ -261,4 +261,34 @@ diag 'testing transaction saved searches'; is( $search->Name, 'txn chart 1', 'loaded search' ); } + +diag 'testing asset saved searches'; +{ + $m->get_ok("/Search/Chart.html?Class=RT::Assets&Query=id>0"); + $m->submit_form( + form_name => 'SaveSearch', + fields => { + SavedSearchDescription => 'asset chart 1', + SavedSearchOwner => $owner, + }, + button => 'SavedSearchSave', + ); + $m->form_name('SaveSearch'); + @saved_search_ids = $m->current_form->find_input('SavedSearchLoad')->possible_values; + shift @saved_search_ids; # first value is blank + my $chart_without_updates_id = $saved_search_ids[0]; + ok( $chart_without_updates_id, 'got a saved chart id' ); + is( scalar @saved_search_ids, 1, 'got only one saved chart id' ); + + my ( $privacy, $user_id, $search_id ) = $chart_without_updates_id =~ /^(RT::User-(\d+))-SavedSearch-(\d+)$/; + my $user = RT::User->new( RT->SystemUser ); + $user->Load($user_id); + is( $user->Name, 'root', 'loaded user' ); + my $currentuser = RT::CurrentUser->new($user); + + my $search = RT::SavedSearch->new($currentuser); + $search->Load( $privacy, $search_id ); + is( $search->Name, 'asset chart 1', 'loaded search' ); +} + done_testing; From 2765706a1baeafd2cf778a576acc037f79a18078 Mon Sep 17 00:00:00 2001 From: sunnavy Date: Tue, 5 Dec 2023 10:10:12 -0500 Subject: [PATCH 5/7] Increase "Group By" rows to 5 to group by 2 more fields --- share/html/Search/Chart.html | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html index 07338561265..14774cdb375 100644 --- a/share/html/Search/Chart.html +++ b/share/html/Search/Chart.html @@ -196,6 +196,28 @@ Class => $Class, &> +
<% loc('and then') %> + <& Elements/SelectGroupBy, + Name => 'GroupBy', + Query => $query{Query}, + Default => $query{'GroupBy'}[3] // q{}, + ShowEmpty => 1, + Stacked => $query{'GroupBy'}[3] && ($query{'GroupBy'}[3] eq ($query{StackedGroupBy} // '')) ? 1 : 0, + StackedId => 'StackedGroupBy-4', + Class => $Class, + &> +
+
<% loc('and then') %> + <& Elements/SelectGroupBy, + Name => 'GroupBy', + Query => $query{Query}, + Default => $query{'GroupBy'}[4] // q{}, + ShowEmpty => 1, + Stacked => $query{'GroupBy'}[4] && ($query{'GroupBy'}[4] eq ($query{StackedGroupBy} // '')) ? 1 : 0, + StackedId => 'StackedGroupBy-5', + Class => $Class, + &> +
<&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &> From 68fa9b30f2863f6d628b063ff08ce2e75d4e64aa Mon Sep 17 00:00:00 2001 From: sunnavy Date: Tue, 9 Jan 2024 16:26:06 -0500 Subject: [PATCH 6/7] Log about the inaccurate chart data if UseSQLForACLChecks is disabled --- lib/RT/Report.pm | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm index 81f9b74c438..5e76fa29edf 100644 --- a/lib/RT/Report.pm +++ b/lib/RT/Report.pm @@ -606,6 +606,9 @@ sub SetupGroupings { } $self->{'_sql_current_user_can_see_applied'} = 1 } + elsif ( !RT->Config->Get('UseSQLForACLChecks') ) { + RT->Logger->notice('UseSQLForACLChecks is disabled, results might be inaccurate'); + } my %res = $self->_SetupGroupings(%args); From 6b84e21b05cf9bb3efaa5914c42b8fe87fc73871 Mon Sep 17 00:00:00 2001 From: sunnavy Date: Tue, 16 Jan 2024 07:56:48 -0500 Subject: [PATCH 7/7] Support to group by Name/Description for asset charts --- lib/RT/Report/Assets.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/RT/Report/Assets.pm b/lib/RT/Report/Assets.pm index a1590c3526a..4a8aee6b270 100644 --- a/lib/RT/Report/Assets.pm +++ b/lib/RT/Report/Assets.pm @@ -67,6 +67,8 @@ This is the backend class for asset search charts. our @GROUPINGS = ( Status => 'Enum', #loc_left_pair + Name => 'Enum', #loc_left_pair + Description => 'Enum', #loc_left_pair Catalog => 'Catalog', #loc_left_pair Creator => 'User', #loc_left_pair LastUpdatedBy => 'User', #loc_left_pair