diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm index 8326ab92e60..46497aa48f1 100644 --- a/lib/RT/CustomField.pm +++ b/lib/RT/CustomField.pm @@ -2381,6 +2381,8 @@ sub CleanupDefaultValues { } } +sub IsNumeric { 0 } + =head2 id Returns the current value of id. diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm index 57c7240d57d..31836b38305 100644 --- a/lib/RT/Interface/Web/MenuBuilder.pm +++ b/lib/RT/Interface/Web/MenuBuilder.pm @@ -760,6 +760,9 @@ sub BuildMainNav { elsif ( $class eq 'RT::Assets' ) { $current_search_menu->child( bulk => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" ); } + elsif ( $class eq 'RT::Transactions' ) { + $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" ); + } my $more = $current_search_menu->child( more => title => loc('Feeds') ); diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm new file mode 100644 index 00000000000..9b32ef4cdfb --- /dev/null +++ b/lib/RT/Report.pm @@ -0,0 +1,1426 @@ +# 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; + +use strict; +use warnings; +use 5.010; +use Scalar::Util qw(weaken); +use RT::User; + +our %GROUPINGS_META = ( + Queue => { + Display => sub { + my $self = shift; + my %args = (@_); + + my $queue = RT::Queue->new( $self->CurrentUser ); + $queue->Load( $args{'VALUE'} ); + return $queue->Name; + }, + Localize => 1, + Distinct => 1, + }, + Priority => { + Sort => 'numeric raw', + Distinct => 1, + }, + User => { + SubFields => [grep RT::User->_Accessible($_, "public"), qw( + Name RealName NickName + EmailAddress + Organization + Lang City Country Timezone + )], + Function => 'GenerateUserFunction', + Distinct => 1, + }, + Watcher => { + SubFields => sub { + my $self = shift; + my $args = shift; + + my %fields = ( + user => [ grep RT::User->_Accessible( $_, "public" ), + qw( Name RealName NickName EmailAddress Organization Lang City Country Timezone) ], + principal => [ grep RT::User->_Accessible( $_, "public" ), qw( Name ) ], + ); + + 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 ); + } + while ( my $cr = $crs->Next ) { + for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) { + push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field"; + } + } + } + else { + for my $field ( @{ $fields{principal} } ) { + push @res, [ $args->{key}, $field ], "$args->{key}.$field"; + } + } + return @res; + }, + Function => 'GenerateWatcherFunction', + Label => sub { + my $self = shift; + my %args = (@_); + + my $key; + if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) { + my $id = $1; + my $cr = RT::CustomRole->new( $self->CurrentUser ); + $cr->Load($id); + $key = $cr->Name; + } + else { + $key = $args{KEY}; + } + return join ' ', $key, $args{SUBKEY}; + }, + Display => sub { + my $self = shift; + my %args = (@_); + # VALUE could be "(no value)" from perl level calculation + if ( $args{FIELD} eq 'id' && ($args{'VALUE'} // '') !~ /\D/ ) { + my $princ = RT::Principal->new( $self->CurrentUser ); + $princ->Load( $args{'VALUE'} ) if $args{'VALUE'}; + return $self->loc('(no value)') unless $princ->Id; + return $princ->IsGroup ? $self->loc( 'Group: [_1]', $princ->Object->Name ) : $princ->Object->Name; + } + else { + return $args{VALUE}; + } + }, + Distinct => sub { + my $self = shift; + my %args = @_; + if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) { + my $id = $1; + my $obj = RT::CustomRole->new( RT->SystemUser ); + $obj->Load( $id ); + if ( $obj->MaxValues == 1 ) { + return 1; + } + else { + return 0; + } + } + return 0; + }, + }, + Date => { + SubFields => [qw( + Time + Hourly Hour + Date Daily + DayOfWeek Day DayOfMonth DayOfYear + Month Monthly + Year Annually + WeekOfYear + )], # loc_qw + StrftimeFormat => { + Time => '%T', + Hourly => '%Y-%m-%d %H', + Hour => '%H', + Date => '%F', + Daily => '%F', + DayOfWeek => '%w', + Day => '%F', + DayOfMonth => '%d', + DayOfYear => '%j', + Month => '%m', + Monthly => '%Y-%m', + Year => '%Y', + Annually => '%Y', + WeekOfYear => '%W', + }, + Function => 'GenerateDateFunction', + Display => sub { + my $self = shift; + my %args = (@_); + + my $raw = $args{'VALUE'}; + return $raw unless defined $raw; + + if ( $args{'SUBKEY'} eq 'DayOfWeek' ) { + return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]); + } + elsif ( $args{'SUBKEY'} eq 'Month' ) { + return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]); + } + return $raw; + }, + Sort => 'raw', + Distinct => 1, + }, + CustomField => { + SubFields => sub { + 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); + } + while ( my $CustomField = $CustomFields->Next ) { + push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}"; + } + return @res; + }, + Function => 'GenerateCustomFieldFunction', + Label => sub { + my $self = shift; + my %args = (@_); + + my ($cf) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ ); + if ( $cf =~ /^\d+$/ ) { + + # When we render label in charts, the cf could surely be + # seen by current user(SubFields above checks rights), but + # we can't use current user to load cf here because the + # right might be granted at queue level and it's not + # straightforward to add a related queue as context object + # here. That's why we use RT->SystemUser here instead. + + my $obj = RT::CustomField->new( RT->SystemUser ); + $obj->Load( $cf ); + $cf = $obj->Name; + } + + return 'Custom field [_1]', $cf; + }, + Distinct => sub { + my $self = shift; + my %args = @_; + if ( $args{SUBKEY} =~ /\{(\d+)\}/ ) { + my $id = $1; + my $obj = RT::CustomField->new( RT->SystemUser ); + $obj->Load( $id ); + if ( $obj->MaxValues == 1 ) { + return 1; + } + else { + return 0; + } + } + return 0; + }, + }, + Enum => { + Localize => 1, + Distinct => 1, + }, + Duration => { + SubFields => [ qw/Default Hour Day Week Month Year/ ], + Localize => 1, + Short => 0, + Show => 1, + Sort => 'duration', + Distinct => 1, + }, + DurationInBusinessHours => { + SubFields => [ qw/Default Hour/ ], + Localize => 1, + Short => 0, + Show => 1, + Sort => 'duration', + Distinct => 1, + Display => sub { + my $self = shift; + my %args = (@_); + my $value = $args{VALUE}; + my $format = $args{FORMAT} || 'text'; + if ( $format eq 'html' ) { + RT::Interface::Web::EscapeHTML(\$value); + my $css_class; + if ( my $style = $self->__Value('_css_class') ) { + $css_class = $style->{$args{NAME}}; + }; + return $value unless $css_class; + return qq{$value}; + } + else { + return $value; + } + }, + }, +); + +our %STATISTICS_META = ( + Count => { + Function => sub { + my $self = shift; + my $field = shift || 'id'; + + return ( + FUNCTION => 'COUNT', + FIELD => 'id' + ); + }, + }, + Simple => { + Function => sub { + my $self = shift; + my ($function, $field) = @_; + return (FUNCTION => $function, FIELD => $field); + }, + }, + Time => { + Function => sub { + my $self = shift; + my ($function, $field) = @_; + return (FUNCTION => "$function(?)*60", FIELD => $field); + }, + Display => 'DurationAsString', + }, + TimeAll => { + SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, + Function => sub { + my $self = shift; + my $field = shift; + return ( + Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field }, + Average => { FUNCTION => "AVG(?)*60", FIELD => $field }, + Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field }, + Total => { FUNCTION => "SUM(?)*60", FIELD => $field }, + ); + }, + Display => 'DurationAsString', + }, + DateTimeInterval => { + Function => sub { + my $self = shift; + my ($function, $from, $to) = @_; + + my $interval = $self->_Handle->DateTimeIntervalFunction( + From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) }, + To => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) }, + ); + + return (FUNCTION => "$function($interval)"); + }, + Display => 'DurationAsString', + }, + DateTimeIntervalAll => { + SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, + Function => sub { + my $self = shift; + my ($from, $to) = @_; + + my $interval = $self->_Handle->DateTimeIntervalFunction( + From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) }, + To => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) }, + ); + + return ( + Minimum => { FUNCTION => "MIN($interval)" }, + Average => { FUNCTION => "AVG($interval)" }, + Maximum => { FUNCTION => "MAX($interval)" }, + Total => { FUNCTION => "SUM($interval)" }, + ); + }, + Display => 'DurationAsString', + }, + CustomDateRange => { + Display => 'DurationAsString', + Function => sub {}, # Placeholder to use the same DateTimeInterval handling + }, + CustomDateRangeAll => { + SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, + Function => sub { + my $self = shift; + + # To use the same DateTimeIntervalAll handling, not real SQL + return ( + Minimum => { FUNCTION => "MIN" }, + Average => { FUNCTION => "AVG" }, + Maximum => { FUNCTION => "MAX" }, + Total => { FUNCTION => "SUM" }, + ); + }, + Display => 'DurationAsString', + }, + CustomFieldNumericRange => { + Function => sub { + my $self = shift; + my $function = shift; + my $id = shift; + my $cf = RT::CustomField->new( RT->SystemUser ); + $cf->Load($id); + my ($ocfv_alias) = $self->_CustomFieldJoin( $id, $cf ); + my $cast = $self->_CastToDecimal('Content'); + my $precision = $self->_CustomFieldNumericPrecision($cf) // 3; + return ( + FUNCTION => $function eq 'AVG' ? "ROUND($function($cast), $precision)" : "$function($cast)", + ALIAS => $ocfv_alias, + ); + }, + }, + CustomFieldNumericRangeAll => { + SubValues => sub { return ( 'Minimum', 'Average', 'Maximum', 'Total' ) }, + Function => sub { + my $self = shift; + my $id = shift; + my $cf = RT::CustomField->new( RT->SystemUser ); + $cf->Load($id); + my ($ocfv_alias) = $self->_CustomFieldJoin( $id, $cf ); + my $cast = $self->_CastToDecimal('Content'); + my $precision = $self->_CustomFieldNumericPrecision($cf) // 3; + + return ( + Minimum => { FUNCTION => "MIN($cast)", ALIAS => $ocfv_alias }, + Average => { FUNCTION => "ROUND(AVG($cast), $precision)", ALIAS => $ocfv_alias }, + Maximum => { FUNCTION => "MAX($cast)", ALIAS => $ocfv_alias }, + Total => { FUNCTION => "SUM($cast)", ALIAS => $ocfv_alias }, + ); + }, + }, +); + +sub Groupings { + my $self = shift; + my %args = (@_); + + my @fields; + + my @tmp = $self->_Groupings(); + while ( my ($field, $type) = splice @tmp, 0, 2 ) { + my $meta = $GROUPINGS_META{ $type } || {}; + unless ( $meta->{'SubFields'} ) { + push @fields, [$field, $field], $field; + } + elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) { + push @fields, map { ([$field, $_], "$field.$_") } @{ $meta->{'SubFields'} }; + } + elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) { + push @fields, $code->( $self, { %args, key => $field } ); + } + else { + $RT::Logger->error( + "$type has unsupported SubFields." + ." Not an array, a method name or a code reference" + ); + } + } + return @fields; +} + +sub IsValidGrouping { + my $self = shift; + my %args = (@_); + return 0 unless $args{'GroupBy'}; + + my ($key, $subkey) = split /(?_GroupingType( $key ); + return 0 unless $type; + return 1 unless $subkey; + + my $meta = $GROUPINGS_META{ $type } || {}; + unless ( $meta->{'SubFields'} ) { + return 0; + } + elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) { + return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} }; + } + elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'}, 'silent' ) ) { + return 1 if grep $_ eq "$key.$subkey", $code->( $self, { %args, key => $key } ); + } + return 0; +} + +sub Statistics { + my $self = shift; + my @items = $self->_Statistics; + return @items, $self->_NumericCustomFields(@_); +} + +sub Label { + my $self = shift; + my $column = shift; + + my $info = $self->ColumnInfo( $column ); + unless ( $info ) { + $RT::Logger->error("Unknown column '$column'"); + return $self->CurrentUser->loc('(Incorrect data)'); + } + + if ( $info->{'META'}{'Label'} ) { + my $code = $self->FindImplementationCode( $info->{'META'}{'Label'} ); + return $self->CurrentUser->loc( $code->( $self, %$info ) ) + if $code; + } + + my $res = ''; + if ( $info->{'TYPE'} eq 'statistic' ) { + $res = $info->{'INFO'}[0]; + } + else { + $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'}; + } + return $self->CurrentUser->loc( $res ); +} + +sub ColumnInfo { + my $self = shift; + my $column = shift; + + return $self->{'column_info'}{$column}; +} + +sub ColumnsList { + my $self = shift; + return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} } + keys %{ $self->{'column_info'} || {} }; +} + +sub SetupGroupings { + my $self = shift; + my %args = ( + Query => undef, + GroupBy => undef, + Function => undef, + @_ + ); + + my $i = 0; + + my @group_by = grep defined && length, + ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'}); + @group_by = $self->DefaultGroupBy unless @group_by; + + my $distinct_results = 1; + foreach my $e ( splice @group_by ) { + unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) { + RT->Logger->error("'$e' is not a valid grouping for reports; skipping"); + next; + } + my ($key, $subkey) = split /(?_FieldToFunction( KEY => $key, SUBKEY => $subkey ) }; + $e->{'TYPE'} = 'grouping'; + $e->{'INFO'} = $self->_GroupingType($key); + $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} }; + $e->{'POSITION'} = $i++; + if ( my $distinct = $e->{'META'}{Distinct} ) { + if ( ref($distinct) eq 'CODE' ) { + $distinct_results = 0 unless $distinct->( $self, KEY => $key, SUBKEY => $subkey ); + } + } + else { + $distinct_results = 0; + } + push @group_by, $e; + } + $self->{_distinct_results} = $distinct_results; + + $self->GroupBy( map { { + ALIAS => $_->{'ALIAS'}, + FIELD => $_->{'FIELD'}, + FUNCTION => $_->{'FUNCTION'}, + } } @group_by ); + + my %res = (Groups => [], Functions => []); + my %column_info; + + foreach my $group_by ( @group_by ) { + $group_by->{'NAME'} = $self->Column( %$group_by ); + $column_info{ $group_by->{'NAME'} } = $group_by; + push @{ $res{'Groups'} }, $group_by->{'NAME'}; + } + + my %statistics = $self->Statistics(%args); + my @function = grep defined && length, + ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'}); + push @function, 'COUNT' unless @function; + foreach my $e ( @function ) { + $e = { + TYPE => 'statistic', + KEY => $e, + INFO => $statistics{ $e }, + META => $STATISTICS_META{ $statistics{ $e }[1] }, + POSITION => $i++, + }; + unless ( $e->{'INFO'} && $e->{'META'} ) { + $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report"); + $e->{'FUNCTION'} = 'NULL'; + $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' ); + } + elsif ( $e->{'META'}{'Function'} ) { + my $code = $self->FindImplementationCode( $e->{'META'}{'Function'} ); + unless ( $code ) { + $e->{'FUNCTION'} = 'NULL'; + $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' ); + } + elsif ( $e->{'META'}{'SubValues'} ) { + my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] ); + $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++; + while ( my ($k, $v) = each %tmp ) { + $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v ); + @{ $e->{'MAP'}{ $k } }{'FUNCTION', 'ALIAS', 'FIELD'} = + @{ $v }{'FUNCTION', 'ALIAS', 'FIELD'}; + } + } + else { + my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] ); + $e->{'NAME'} = $self->Column( %tmp ); + @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'}; + } + } + elsif ( $e->{'META'}{'Calculate'} ) { + $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++; + } + push @{ $res{'Functions'} }, $e->{'NAME'}; + $column_info{ $e->{'NAME'} } = $e; + } + + $self->{'column_info'} = \%column_info; + + return %res; +} + +=head2 _FieldToFunction FIELD + +Returns a tuple of the field or a database function to allow grouping on that field. + +=cut + +sub _FieldToFunction { + my $self = shift; + my %args = (@_); + + $args{'FIELD'} ||= $args{'KEY'}; + + my $meta = $GROUPINGS_META{ $self->_GroupingType( $args{'KEY'} ) }; + return ('FUNCTION' => 'NULL') unless $meta; + + return %args unless $meta->{'Function'}; + + my $code = $self->FindImplementationCode( $meta->{'Function'} ); + return ('FUNCTION' => 'NULL') unless $code; + + return $code->( $self, %args ); +} + +sub SortEntries { + my $self = shift; + + $self->_DoSearch if $self->{'must_redo_search'}; + return unless $self->{'items'} && @{ $self->{'items'} }; + + my @groups = + grep $_->{'TYPE'} eq 'grouping', + map $self->ColumnInfo($_), + $self->ColumnsList; + return unless @groups; + + my @SORT_OPS; + my $by_multiple = sub ($$) { + for my $f ( @SORT_OPS ) { + my $r = $f->($_[0], $_[1]); + return $r if $r; + } + }; + my @data = map [$_], @{ $self->{'items'} }; + + for ( my $i = 0; $i < @groups; $i++ ) { + my $group_by = $groups[$i]; + my $idx = $i+1; + + my $order = $group_by->{'META'}{Sort} || 'label'; + my $method = $order =~ /label$/ ? 'LabelValue' : 'RawValue'; + + unless ($order =~ /^numeric/) { + # Traverse the values being used for labels. + # If they all look like numbers or undef, flag for a numeric sort. + my $looks_like_number = 1; + foreach my $item (@data){ + my $label = $item->[0]->$method($group_by->{'NAME'}); + + $looks_like_number = 0 + unless (not defined $label) + or Scalar::Util::looks_like_number( $label ); + } + $order = "numeric $order" if $looks_like_number; + } + + if ( $order eq 'label' ) { + push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] }; + $method = 'LabelValue'; + } + elsif ( $order eq 'numeric label' ) { + my $nv = $self->loc("(no value)"); + # Sort the (no value) elements first, by comparing for them + # first, and falling back to a numeric sort on all other + # values. + push @SORT_OPS, sub { + (($_[0][$idx] ne $nv) <=> ($_[1][$idx] ne $nv)) + || ( $_[0][$idx] <=> $_[1][$idx] ) }; + $method = 'LabelValue'; + } + elsif ( $order eq 'raw' ) { + push @SORT_OPS, sub { ($_[0][$idx]//'') cmp ($_[1][$idx]//'') }; + $method = 'RawValue'; + } + elsif ( $order eq 'numeric raw' ) { + push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] }; + $method = 'RawValue'; + } + elsif ( $order eq 'duration' ) { + push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] }; + $method = 'DurationValue'; + } else { + $RT::Logger->error("Unknown sorting function '$order'"); + next; + } + $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data; + } + $self->{'items'} = [ + map $_->[0], + sort $by_multiple @data + ]; +} + +sub PostProcessRecords { + my $self = shift; + + my $info = $self->{'column_info'}; + foreach my $column ( values %$info ) { + next unless $column->{'TYPE'} eq 'statistic'; + if ( $column->{'META'}{'Calculate'} ) { + $self->CalculatePostFunction( $column ); + } + elsif ( $column->{'META'}{'SubValues'} ) { + $self->MapSubValues( $column ); + } + } +} + +sub CalculatePostFunction { + my $self = shift; + my $info = shift; + + my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} ); + unless ( $code ) { + # TODO: fill in undefs + return; + } + + my $column = $info->{'NAME'}; + + my $base_query = $self->Query; + foreach my $item ( @{ $self->{'items'} } ) { + $item->{'values'}{ lc $column } = $code->( + $self, + Query => join( + ' AND ', map "($_)", grep defined && length, $base_query, $item->Query, + ), + ); + $item->{'fetched'}{ lc $column } = 1; + } +} + +sub MapSubValues { + my $self = shift; + my $info = shift; + + my $to = $info->{'NAME'}; + my $map = $info->{'MAP'}; + + foreach my $item ( @{ $self->{'items'} } ) { + my $dst = $item->{'values'}{ lc $to } = { }; + while (my ($k, $v) = each %{ $map } ) { + $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} }; + # This mirrors the logic in RT::Record::__Value When that + # ceases tp use the UTF-8 flag as a character/byte + # distinction from the database, this can as well. + utf8::decode( $dst->{ $k } ) + if defined $dst->{ $k } + and not utf8::is_utf8( $dst->{ $k } ); + delete $item->{'fetched'}{ lc $v->{'NAME'} }; + } + $item->{'fetched'}{ lc $to } = 1; + } +} + +sub GenerateDateFunction { + my $self = shift; + my %args = @_; + + my $tz; + if ( RT->Config->Get('ChartsTimezonesInDB') ) { + my $to = $self->CurrentUser->UserObj->Timezone + || RT->Config->Get('Timezone'); + $tz = { From => 'UTC', To => $to } + if $to && lc $to ne 'utc'; + } + + $args{'FUNCTION'} = $RT::Handle->DateTimeFunction( + Type => $args{'SUBKEY'}, + Field => $self->NotSetDateToNullFunction, + Timezone => $tz, + ); + return %args; +} + +sub GenerateCustomFieldFunction { + my $self = shift; + my %args = @_; + + my ($name) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ ); + my $cf = RT::CustomField->new( $self->CurrentUser ); + $cf->Load($name); + unless ( $cf->id ) { + $RT::Logger->error("Couldn't load CustomField #$name"); + @args{qw(FUNCTION FIELD)} = ('NULL', undef); + } else { + my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf); + @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content'); + } + return %args; +} + +sub GenerateUserFunction { + my $self = shift; + my %args = @_; + + my $column = $args{'SUBKEY'} || 'Name'; + my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"} + ||= $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => $args{'FIELD'}, + TABLE2 => 'Users', + FIELD2 => 'id', + ); + @args{qw(ALIAS FIELD)} = ($u_alias, $column); + return %args; +} + +sub GenerateWatcherFunction { + my $self = shift; + my %args = @_; + + my $type = $args{'FIELD'}; + $type = '' if $type eq 'Watcher'; + + my $single_role; + + if ( $type =~ s!^CustomRole\.\{(\d+)\}!RT::CustomRole-$1! ) { + my $id = $1; + my $cr = RT::CustomRole->new( $self->CurrentUser ); + $cr->Load($id); + $single_role = 1 if $cr->MaxValues; + } + + my $column = $single_role ? $args{'SUBKEY'} || 'Name' : 'id'; + + my $alias = $self->{"_sql_report_watcher_alias_$type"}; + unless ( $alias ) { + my $groups = $self->_RoleGroupsJoin(Name => $type); + my $group_members = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $groups, + FIELD1 => 'id', + TABLE2 => 'GroupMembers', + FIELD2 => 'GroupId', + ENTRYAGGREGATOR => 'AND', + ); + $alias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $group_members, + FIELD1 => 'MemberId', + TABLE2 => $single_role ? 'Users' : 'Principals', + FIELD2 => 'id', + ); + $self->{"_sql_report_watcher_alias_$type"} = $alias; + } + @args{qw(ALIAS FIELD)} = ($alias, $column); + + return %args; +} + +sub DurationAsString { + my $self = shift; + my %args = @_; + my $v = $args{'VALUE'}; + my $max_unit = $args{INFO} && ref $args{INFO}[-1] && $args{INFO}[-1]{business_time} ? 'hour' : 'year'; + my $format = $args{FORMAT} || 'text'; + + my $css_class; + if ( $format eq 'html' + && $self->can('__Value') + && $args{INFO} + && ref $args{INFO}[-1] + && $args{INFO}[-1]{business_time} ) + { + + # 1 means business hours in SLA, its css is already generated and saved in _css_class. + if ( $args{INFO}[-1]{business_time} eq '1' ) { + my $style = $self->__Value('_css_class'); + my $field; + if ( $args{INFO}[1] =~ /^CustomDateRange/ ) { + $field = $args{INFO}[-2]; + } + elsif ( $args{INFO}[1] =~ /^DateTimeInterval/ ) { + $field = join ' to ', $args{INFO}[-3], $args{INFO}[-2]; + } + + $css_class = $style->{$field} if $style && $field; + } + else { + $css_class = 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $args{INFO}[-1]{business_time} ) + } + } + + unless ( ref $v ) { + my $value; + if ( defined $v && length $v ) { + $value = RT::Date->new( $self->CurrentUser )->DurationAsString( + $v, + Show => 3, + Short => 1, + MaxUnit => $max_unit, + ); + } + else { + $value = $self->loc("(no value)"); + } + + if ( $format eq 'html' ) { + RT::Interface::Web::EscapeHTML(\$value); + return $value unless $css_class; + return qq{$value}; + } + else { + return $value; + } + + } + + my $date = RT::Date->new( $self->CurrentUser ); + my %res = %$v; + foreach my $e ( values %res ) { + $e = $date->DurationAsString( $e, Short => 1, Show => 3, MaxUnit => $max_unit ) + if defined $e && length $e; + $e = $self->loc("(no value)") unless defined $e && length $e; + } + + if ( $format eq 'html' ) { + for my $key ( keys %res ) { + RT::Interface::Web::EscapeHTML(\$res{$key}); + next unless $css_class; + $res{$key} = qq{$res{$key}}; + } + } + return \%res; +} + +sub LabelValueCode { + my $self = shift; + my $name = shift; + + my $display = $self->ColumnInfo( $name )->{'META'}{'Display'}; + return undef unless $display; + return $self->FindImplementationCode( $display ); +} + + +sub FindImplementationCode { + my $self = shift; + my $value = shift; + my $silent = shift; + + my $code; + unless ( $value ) { + $RT::Logger->error("Value is not defined. Should be method name or code reference") + unless $silent; + return undef; + } + elsif ( !ref $value ) { + $code = $self->can( $value ); + unless ( $code ) { + $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" ) + unless $silent; + return undef; + } + } + elsif ( ref( $value ) eq 'CODE' ) { + $code = $value; + } + else { + $RT::Logger->error("$value is not method name or code reference") + unless $silent; + return undef; + } + return $code; +} + +sub Serialize { + my $self = shift; + + my %clone = %$self; +# current user, handle and column_info + delete @clone{'user', 'DBIxHandle', 'column_info'}; + $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ]; + $clone{'column_info'} = {}; + while ( my ($k, $v) = each %{ $self->{'column_info'} } ) { + $clone{'column_info'}{$k} = { %$v }; + delete $clone{'column_info'}{$k}{'META'}; + } + return \%clone; +} + +sub Deserialize { + my $self = shift; + my $data = shift; + + $self->CleanSlate; + %$self = (%$self, %$data); + + $self->{'items'} = [ + map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r } + @{ $self->{'items'} } + ]; + foreach my $e ( values %{ $self->{column_info} } ) { + $e->{'META'} = $e->{'TYPE'} eq 'grouping' + ? $GROUPINGS_META{ $e->{'INFO'} } + : $STATISTICS_META{ $e->{'INFO'}[1] } + } +} + + +sub FormatTable { + my $self = shift; + my %columns = @_; + + my (@head, @body, @footer); + + @head = ({ cells => []}); + foreach my $column ( @{ $columns{'Groups'} } ) { + push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) }; + } + + my $i = 0; + while ( my $entry = $self->Next ) { + $body[ $i ] = { even => ($i+1)%2, cells => [] }; + $i++; + } + @footer = ({ even => ++$i%2, cells => []}) if $self->{_distinct_results}; + + my $g = 0; + foreach my $column ( @{ $columns{'Groups'} } ) { + $i = 0; + my $last; + while ( my $entry = $self->Next ) { + my $value = $entry->LabelValue( $column, 'html' ); + if ( !$last || $last->{'value'} ne $value ) { + push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value }; + $last->{even} = $g++ % 2 + unless $column eq $columns{'Groups'}[-1]; + } + else { + $i++; + $last->{rowspan} = ($last->{rowspan}||1) + 1; + } + } + } + push @{ $footer[0]{'cells'} }, { + type => 'label', + value => $self->loc('Total'), + colspan => scalar @{ $columns{'Groups'} }, + } if $self->{_distinct_results}; + + my $pick_color = do { + my @colors = RT->Config->Get("ChartColors"); + sub { $colors[ $_[0] % @colors - 1 ] } + }; + + my $function_count = 0; + foreach my $column ( @{ $columns{'Functions'} } ) { + $i = 0; + + my $info = $self->ColumnInfo( $column ); + + my @subs = (''); + if ( $info->{'META'}{'SubValues'} ) { + @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->( + $self + ); + } + + my %total; + unless ( $info->{'META'}{'NoTotals'} ) { + while ( my $entry = $self->Next ) { + my $raw = $entry->RawValue( $column ) || {}; + $raw = { '' => $raw } unless ref $raw; + $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs; + } + @subs = grep $total{$_}, @subs + unless $info->{'META'}{'NoHideEmpty'}; + } + + my $label = $self->Label( $column ); + + unless (@subs) { + while ( my $entry = $self->Next ) { + push @{ $body[ $i++ ]{'cells'} }, { + type => 'value', + value => undef, + query => $entry->Query, + }; + } + push @{ $head[0]{'cells'} }, { + type => 'head', + value => $label, + rowspan => scalar @head, + color => $pick_color->(++$function_count), + }; + push @{ $footer[0]{'cells'} }, { type => 'value', value => undef } if $self->{_distinct_results}; + next; + } + + if ( @subs > 1 && @head == 1 ) { + $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} }; + } + + if ( @subs == 1 ) { + push @{ $head[0]{'cells'} }, { + type => 'head', + value => $label, + rowspan => scalar @head, + color => $pick_color->(++$function_count), + }; + } else { + push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs }; + push @{ $head[1]{'cells'} }, { type => 'head', value => $_, color => $pick_color->(++$function_count) } + foreach @subs; + } + + while ( my $entry = $self->Next ) { + my $query = $entry->Query; + my $value = $entry->LabelValue( $column, 'html' ) || {}; + $value = { '' => $value } unless ref $value; + foreach my $e ( @subs ) { + push @{ $body[ $i ]{'cells'} }, { + type => 'value', + value => $value->{ $e }, + query => $query, + }; + } + $i++; + } + + next unless $self->{_distinct_results}; + unless ( $info->{'META'}{'NoTotals'} ) { + my $total_code = $self->LabelValueCode( $column ); + foreach my $e ( @subs ) { + my $total = $total{ $e }; + $total = $total_code->( $self, %$info, VALUE => $total, FORMAT => 'html' ) + if $total_code; + push @{ $footer[0]{'cells'} }, { type => 'value', value => $total }; + } + } + else { + foreach my $e ( @subs ) { + push @{ $footer[0]{'cells'} }, { type => 'value', value => undef }; + } + } + } + + return thead => \@head, tbody => \@body, tfoot => \@footer; +} + +sub _CalculateTime { + my $self = shift; + my ( $type, $value, $current ) = @_; + + return $current unless defined $value; + + if ( $type eq 'SUM' ) { + $current += $value; + } + elsif ( $type eq 'AVG' ) { + $current ||= {}; + $current->{total} += $value; + $current->{count}++; + $current->{calculate} ||= sub { + my $item = shift; + return sprintf '%.0f', $item->{total} / $item->{count}; + }; + } + elsif ( $type eq 'MAX' ) { + $current = $value unless $current && $current > $value; + } + elsif ( $type eq 'MIN' ) { + $current = $value unless $current && $current < $value; + } + else { + RT->Logger->error("Unsupported type $type"); + } + return $current; +} + +sub _SetupCustomDateRanges { + my $self = shift; + my %names; + my @groupings = $self->_Groupings; + my @statistics = $self->_Statistics; + + # Remove old custom date range groupings + for my $field ( grep {ref} @statistics) { + if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) { + $names{ $field->[2] } = 1; + } + } + + my ( @new_groupings, @new_statistics ); + while (@groupings) { + my $name = shift @groupings; + my $type = shift @groupings; + if ( !$names{$name} ) { + push @new_groupings, $name, $type; + } + } + + while (@statistics) { + my $key = shift @statistics; + my $info = shift @statistics; + my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/; + unless ( $name && $names{$name} ) { + push @new_statistics, $key, $info; + } + } + + # Add new ones + my %ranges = $self->_SingularClass->ObjectType->CustomDateRanges; + for my $name ( sort keys %ranges ) { + my %extra_info; + my $spec = $ranges{$name}; + if ( ref $spec && $spec->{business_time} ) { + $extra_info{business_time} = $spec->{business_time}; + } + + push @new_groupings, $name => $extra_info{business_time} ? 'DurationInBusinessHours' : 'Duration'; + push @new_statistics, + ( + "ALL($name)" => [ "Summary of $name", 'CustomDateRangeAll', $name, \%extra_info ], + "SUM($name)" => [ "Total $name", 'CustomDateRange', 'SUM', $name, \%extra_info ], + "AVG($name)" => [ "Average $name", 'CustomDateRange', 'AVG', $name, \%extra_info ], + "MIN($name)" => [ "Minimum $name", 'CustomDateRange', 'MIN', $name, \%extra_info ], + "MAX($name)" => [ "Maximum $name", 'CustomDateRange', 'MAX', $name, \%extra_info ], + ); + } + + $self->_Groupings( @new_groupings ); + $self->_Statistics( @new_statistics ); + + return 1; +} + +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 @items; + while ( my $custom_field = $custom_fields->Next ) { + next unless $custom_field->IsNumeric && $custom_field->SingleValue; + my $id = $custom_field->Id; + my $name = $custom_field->Name; + + push @items, + ( + "ALL(CF.$id)" => [ "Summary of $name", 'CustomFieldNumericRangeAll', $id ], + "SUM(CF.$id)" => [ "Total $name", 'CustomFieldNumericRange', 'SUM', $id ], + "AVG(CF.$id)" => [ "Average $name", 'CustomFieldNumericRange', 'AVG', $id ], + "MIN(CF.$id)" => [ "Minimum $name", 'CustomFieldNumericRange', 'MIN', $id ], + "MAX(CF.$id)" => [ "Maximum $name", 'CustomFieldNumericRange', 'MAX', $id ], + ); + } + return @items; +} + +sub _GroupingType { + my $self = shift; + my $key = shift or return; + # keys for custom roles are like "CustomRole.{1}" + $key = 'CustomRole' if $key =~ /^CustomRole/; + return { $self->_Groupings }->{$key}; +} + +sub _GroupingsMeta { return \%GROUPINGS_META }; +sub _StatisticsMeta { return \%STATISTICS_META }; + +# Return the corresponding @GROUPINGS in subclass +sub _Groupings { + my $self = shift; + my $class = ref($self) || $self; + no strict 'refs'; + + if (@_) { + @{ $class . '::GROUPINGS' } = @_; + } + return @{ $class . '::GROUPINGS' }; +} + +# Return the corresponding @STATISTICS in subclass +sub _Statistics { + my $self = shift; + my $class = ref($self) || $self; + no strict 'refs'; + + if (@_) { + @{ $class . '::STATISTICS' } = @_; + } + return @{ $class . '::STATISTICS' }; +} + +=head2 DefaultGroupBy + +By default, it's the first item in @GROUPINGS. + +=cut + +sub DefaultGroupBy { + my $self = shift; + my $class = ref($self) || $self; + no strict 'refs'; + ${ $class . '::GROUPINGS' }[0]; +} + +# The following methods are more collection related + +sub _PostSearch { + my $self = shift; + if ( $self->{'must_redo_search'} ) { + $RT::Logger->crit( +"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows" + ); + } + else { + $self->PostProcessRecords; + } +} + +sub NewItem { + my $self = shift; + my $res = $self->_SingularClass->new($self->CurrentUser); + $res->{'report'} = $self; + weaken $res->{'report'}; + return $res; +} + +# This is necessary since normally NewItem (above) is used to intuit the +# correct class. However, since we're abusing a subclass, it's incorrect. +sub _RoleGroupClass { die "should be subclassed" } +sub _SingularClass { die "should be subclassed" } + +# Precision can be customized by overriding this method +# it'll be called as $self->_CustomFieldNumericPrecision($cf) +sub _CustomFieldNumericPrecision { 3 } + +RT::Base->_ImportOverlays(); + +1; diff --git a/lib/RT/Report/Entry.pm b/lib/RT/Report/Entry.pm new file mode 100644 index 00000000000..cf88d48652d --- /dev/null +++ b/lib/RT/Report/Entry.pm @@ -0,0 +1,213 @@ +# 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::Entry; + +use warnings; +use strict; + +use base qw/RT::Record/; + +# XXX TODO: how the heck do we acl a report? +sub CurrentUserHasRight {1} + +# RT::Transactions::AddRecord calls CurrentUserCanSee +sub CurrentUserCanSee {1} + +=head2 LabelValue + +If you're pulling a value out of this collection and using it as a label, +you may want the "cleaned up" version. This includes scrubbing 1970 dates +and ensuring that dates are in local not DB timezones. + +=cut + +sub LabelValue { + my $self = shift; + my $name = shift; + my $format = shift || 'text'; + + my $raw = $self->RawValue( $name, @_ ); + if ( my $code = $self->Report->LabelValueCode( $name ) ) { + $raw = $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw, FORMAT => $format ); + return $self->loc('(no value)') unless defined $raw && length $raw; + return $raw; + } + + unless ( ref $raw ) { + return $self->loc('(no value)') unless defined $raw && length $raw; + return $self->loc($raw) if $self->Report->ColumnInfo( $name )->{'META'}{'Localize'}; + return $raw; + } else { + my $loc = $self->Report->ColumnInfo( $name )->{'META'}{'Localize'}; + my %res = %$raw; + if ( $loc ) { + $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res; + $_ = $self->loc($_) foreach values %res; + } + $_ = $self->loc('(no value)') foreach grep !defined || !length, values %res; + return \%res; + } +} + +sub RawValue { + return (shift)->__Value( @_ ); +} + +# Used in RT::SearchBuilder::JoinTransactions and CustomFieldLookupType +sub ObjectType { die "should be subclassed" } + +sub CustomFieldLookupType { + my $self = shift; + return $self->ObjectType->CustomFieldLookupType; +} + +sub Query { + my $self = shift; + + if ( my $ids = $self->{values}{ids} ) { + return join ' OR ', map "id=$_", @$ids; + } + + my @parts; + foreach my $column ( $self->Report->ColumnsList ) { + my $info = $self->Report->ColumnInfo( $column ); + next unless $info->{'TYPE'} eq 'grouping'; + + my $custom = $info->{'META'}{'Query'}; + if ( $custom and my $code = $self->Report->FindImplementationCode( $custom ) ) { + push @parts, $code->( $self, COLUMN => $column, %$info ); + } + else { + my $field = join '.', grep $_, $info->{KEY}, $info->{SUBKEY}; + my $value = $self->RawValue( $column ); + my $op = '='; + if ( defined $value ) { + if ( $info->{INFO} eq 'Watcher' && $info->{FIELD} eq 'id' ) { + + # convert id to name + my $princ = RT::Principal->new( $self->CurrentUser ); + $princ->Load($value); + $value = $princ->Object->Name if $princ->Object; + } + + unless ( $value =~ /^\d+$/ ) { + $value =~ s/(['\\])/\\$1/g; + $value = "'$value'"; + } + } + else { + ($op, $value) = ('IS', 'NULL'); + } + unless ( $field =~ /^[{}\w\.]+$/ ) { + $field =~ s/(['\\])/\\$1/g; + $field = "'$field'"; + } + push @parts, "$field $op $value"; + } + } + return () unless @parts; + return join ' AND ', map "($_)", grep defined && length, @parts; +} + +sub Report { + return $_[0]->{'report'}; +} + +sub DurationValue { + my $self = shift; + my $value = $self->__Value(@_); + + return 0 unless $value; + + my $number; + my $unit; + if ( $value =~ /([\d,]+)(?:s| second)/ ) { + $number = $1; + $unit = 1; + } + elsif ( $value =~ /([\d,]+)(?:m| minute)/ ) { + $number = $1; + $unit = $RT::Date::MINUTE; + } + elsif ( $value =~ /([\d,]+)(?:h| hour)/ ) { + $number = $1; + $unit = $RT::Date::HOUR; + } + elsif ( $value =~ /([\d,]+)(?:d| day)/ ) { + $number = $1; + $unit = $RT::Date::DAY; + } + elsif ( $value =~ /([\d,]+)(?:W| week)/ ) { + $number = $1; + $unit = $RT::Date::WEEK; + } + elsif ( $value =~ /([\d,]+)(?:M| month)/ ) { + $number = $1; + $unit = $RT::Date::MONTH; + } + elsif ( $value =~ /([\d,]+)(?:Y| year)/ ) { + $number = $1; + $unit = $RT::Date::YEAR; + } + else { + return -.1; # Mark "(no value)" as -1 so it comes before 0 + } + + $number =~ s!,!!g; + my $seconds = $number * $unit; + + if ( $value =~ /([<|>])/ ) { + $seconds += $1 eq '<' ? -1 : 1; + } + return $seconds; +} + +RT::Base->_ImportOverlays(); + +1; diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm index 45af2a26685..f0915ddcde6 100644 --- a/lib/RT/Report/Tickets.pm +++ b/lib/RT/Report/Tickets.pm @@ -48,13 +48,12 @@ package RT::Report::Tickets; -use base qw/RT::Tickets/; +use base qw/RT::Report RT::Tickets/; use RT::Report::Tickets::Entry; use strict; use warnings; use 5.010; -use Scalar::Util qw(weaken); __PACKAGE__->RegisterCustomFieldJoin(@$_) for [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ], @@ -100,280 +99,6 @@ our @GROUPINGS = ( SLA => 'Enum', #loc_left_pair ); -our %GROUPINGS; - -our %GROUPINGS_META = ( - Queue => { - Display => sub { - my $self = shift; - my %args = (@_); - - my $queue = RT::Queue->new( $self->CurrentUser ); - $queue->Load( $args{'VALUE'} ); - return $queue->Name; - }, - Localize => 1, - Distinct => 1, - }, - Priority => { - Sort => 'numeric raw', - Distinct => 1, - }, - User => { - SubFields => [grep RT::User->_Accessible($_, "public"), qw( - Name RealName NickName - EmailAddress - Organization - Lang City Country Timezone - )], - Function => 'GenerateUserFunction', - Distinct => 1, - }, - Watcher => { - SubFields => sub { - my $self = shift; - my $args = shift; - - my %fields = ( - user => [ grep RT::User->_Accessible( $_, "public" ), - qw( Name RealName NickName EmailAddress Organization Lang City Country Timezone) ], - principal => [ grep RT::User->_Accessible( $_, "public" ), qw( Name ) ], - ); - - 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 ); - $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser ); - } - return () unless $queues; - - my $crs = RT::CustomRoles->new( $self->CurrentUser ); - for my $id ( keys %$queues ) { - my $queue = RT::Queue->new( $self->CurrentUser ); - $queue->Load($id); - next unless $queue->id; - - $crs->LimitToObjectId( $queue->id ); - } - while ( my $cr = $crs->Next ) { - for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) { - push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field"; - } - } - } - else { - for my $field ( @{ $fields{principal} } ) { - push @res, [ $args->{key}, $field ], "$args->{key}.$field"; - } - } - return @res; - }, - Function => 'GenerateWatcherFunction', - Label => sub { - my $self = shift; - my %args = (@_); - - my $key; - if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) { - my $id = $1; - my $cr = RT::CustomRole->new( $self->CurrentUser ); - $cr->Load($id); - $key = $cr->Name; - } - else { - $key = $args{KEY}; - } - return join ' ', $key, $args{SUBKEY}; - }, - Display => sub { - my $self = shift; - my %args = (@_); - # VALUE could be "(no value)" from perl level calculation - if ( $args{FIELD} eq 'id' && ($args{'VALUE'} // '') !~ /\D/ ) { - my $princ = RT::Principal->new( $self->CurrentUser ); - $princ->Load( $args{'VALUE'} ) if $args{'VALUE'}; - return $self->loc('(no value)') unless $princ->Id; - return $princ->IsGroup ? $self->loc( 'Group: [_1]', $princ->Object->Name ) : $princ->Object->Name; - } - else { - return $args{VALUE}; - } - }, - Distinct => sub { - my $self = shift; - my %args = @_; - if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) { - my $id = $1; - my $obj = RT::CustomRole->new( RT->SystemUser ); - $obj->Load( $id ); - if ( $obj->MaxValues == 1 ) { - return 1; - } - else { - return 0; - } - } - return 0; - }, - }, - Date => { - SubFields => [qw( - Time - Hourly Hour - Date Daily - DayOfWeek Day DayOfMonth DayOfYear - Month Monthly - Year Annually - WeekOfYear - )], # loc_qw - StrftimeFormat => { - Time => '%T', - Hourly => '%Y-%m-%d %H', - Hour => '%H', - Date => '%F', - Daily => '%F', - DayOfWeek => '%w', - Day => '%F', - DayOfMonth => '%d', - DayOfYear => '%j', - Month => '%m', - Monthly => '%Y-%m', - Year => '%Y', - Annually => '%Y', - WeekOfYear => '%W', - }, - Function => 'GenerateDateFunction', - Display => sub { - my $self = shift; - my %args = (@_); - - my $raw = $args{'VALUE'}; - return $raw unless defined $raw; - - if ( $args{'SUBKEY'} eq 'DayOfWeek' ) { - return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]); - } - elsif ( $args{'SUBKEY'} eq 'Month' ) { - return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]); - } - return $raw; - }, - Sort => 'raw', - Distinct => 1, - }, - CustomField => { - SubFields => sub { - 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 ); - $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser ); - } - return () unless $queues; - - my @res; - - my $CustomFields = RT::CustomFields->new( $self->CurrentUser ); - 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->LimitToQueue($queue->id); - } - $CustomFields->LimitToGlobal; - while ( my $CustomField = $CustomFields->Next ) { - push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}"; - } - return @res; - }, - Function => 'GenerateCustomFieldFunction', - Label => sub { - my $self = shift; - my %args = (@_); - - my ($cf) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ ); - if ( $cf =~ /^\d+$/ ) { - - # When we render label in charts, the cf could surely be - # seen by current user(SubFields above checks rights), but - # we can't use current user to load cf here because the - # right might be granted at queue level and it's not - # straightforward to add a related queue as context object - # here. That's why we use RT->SystemUser here instead. - - my $obj = RT::CustomField->new( RT->SystemUser ); - $obj->Load( $cf ); - $cf = $obj->Name; - } - - return 'Custom field [_1]', $cf; - }, - Distinct => sub { - my $self = shift; - my %args = @_; - if ( $args{SUBKEY} =~ /\{(\d+)\}/ ) { - my $id = $1; - my $obj = RT::CustomField->new( RT->SystemUser ); - $obj->Load( $id ); - if ( $obj->MaxValues == 1 ) { - return 1; - } - else { - return 0; - } - } - return 0; - }, - }, - Enum => { - Localize => 1, - Distinct => 1, - }, - Duration => { - SubFields => [ qw/Default Hour Day Week Month Year/ ], - Localize => 1, - Short => 0, - Show => 1, - Sort => 'duration', - Distinct => 1, - }, - DurationInBusinessHours => { - SubFields => [ qw/Default Hour/ ], - Localize => 1, - Short => 0, - Show => 1, - Sort => 'duration', - Distinct => 1, - Display => sub { - my $self = shift; - my %args = (@_); - my $value = $args{VALUE}; - my $format = $args{FORMAT} || 'text'; - if ( $format eq 'html' ) { - RT::Interface::Web::EscapeHTML(\$value); - my $css_class; - if ( my $style = $self->__Value('_css_class') ) { - $css_class = $style->{$args{NAME}}; - }; - return $value unless $css_class; - return qq{$value}; - } - else { - return $value; - } - }, - }, -); # loc'able strings below generated with (s/loq/loc/): # perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2' @@ -473,201 +198,6 @@ foreach my $pair ( } } -our %STATISTICS; - -our %STATISTICS_META = ( - Count => { - Function => sub { - my $self = shift; - my $field = shift || 'id'; - - return ( - FUNCTION => 'COUNT', - FIELD => 'id' - ); - }, - }, - Simple => { - Function => sub { - my $self = shift; - my ($function, $field) = @_; - return (FUNCTION => $function, FIELD => $field); - }, - }, - Time => { - Function => sub { - my $self = shift; - my ($function, $field) = @_; - return (FUNCTION => "$function(?)*60", FIELD => $field); - }, - Display => 'DurationAsString', - }, - TimeAll => { - SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, - Function => sub { - my $self = shift; - my $field = shift; - return ( - Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field }, - Average => { FUNCTION => "AVG(?)*60", FIELD => $field }, - Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field }, - Total => { FUNCTION => "SUM(?)*60", FIELD => $field }, - ); - }, - Display => 'DurationAsString', - }, - DateTimeInterval => { - Function => sub { - my $self = shift; - my ($function, $from, $to) = @_; - - my $interval = $self->_Handle->DateTimeIntervalFunction( - From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) }, - To => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) }, - ); - - return (FUNCTION => "$function($interval)"); - }, - Display => 'DurationAsString', - }, - DateTimeIntervalAll => { - SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, - Function => sub { - my $self = shift; - my ($from, $to) = @_; - - my $interval = $self->_Handle->DateTimeIntervalFunction( - From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) }, - To => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) }, - ); - - return ( - Minimum => { FUNCTION => "MIN($interval)" }, - Average => { FUNCTION => "AVG($interval)" }, - Maximum => { FUNCTION => "MAX($interval)" }, - Total => { FUNCTION => "SUM($interval)" }, - ); - }, - Display => 'DurationAsString', - }, - CustomDateRange => { - Display => 'DurationAsString', - Function => sub {}, # Placeholder to use the same DateTimeInterval handling - }, - CustomDateRangeAll => { - SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') }, - Function => sub { - my $self = shift; - - # To use the same DateTimeIntervalAll handling, not real SQL - return ( - Minimum => { FUNCTION => "MIN" }, - Average => { FUNCTION => "AVG" }, - Maximum => { FUNCTION => "MAX" }, - Total => { FUNCTION => "SUM" }, - ); - }, - Display => 'DurationAsString', - }, -); - -sub Groupings { - my $self = shift; - my %args = (@_); - - my @fields; - - my @tmp = @GROUPINGS; - while ( my ($field, $type) = splice @tmp, 0, 2 ) { - my $meta = $GROUPINGS_META{ $type } || {}; - unless ( $meta->{'SubFields'} ) { - push @fields, [$field, $field], $field; - } - elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) { - push @fields, map { ([$field, $_], "$field.$_") } @{ $meta->{'SubFields'} }; - } - elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) { - push @fields, $code->( $self, { %args, key => $field } ); - } - else { - $RT::Logger->error( - "$type has unsupported SubFields." - ." Not an array, a method name or a code reference" - ); - } - } - return @fields; -} - -sub IsValidGrouping { - my $self = shift; - my %args = (@_); - return 0 unless $args{'GroupBy'}; - - my ($key, $subkey) = split /(?_GroupingType( $key ); - return 0 unless $type; - return 1 unless $subkey; - - my $meta = $GROUPINGS_META{ $type } || {}; - unless ( $meta->{'SubFields'} ) { - return 0; - } - elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) { - return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} }; - } - elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'}, 'silent' ) ) { - return 1 if grep $_ eq "$key.$subkey", $code->( $self, { %args, key => $key } ); - } - return 0; -} - -sub Statistics { - my $self = shift; - return map { ref($_)? $_->[0] : $_ } @STATISTICS; -} - -sub Label { - my $self = shift; - my $column = shift; - - my $info = $self->ColumnInfo( $column ); - unless ( $info ) { - $RT::Logger->error("Unknown column '$column'"); - return $self->CurrentUser->loc('(Incorrect data)'); - } - - if ( $info->{'META'}{'Label'} ) { - my $code = $self->FindImplementationCode( $info->{'META'}{'Label'} ); - return $self->CurrentUser->loc( $code->( $self, %$info ) ) - if $code; - } - - my $res = ''; - if ( $info->{'TYPE'} eq 'statistic' ) { - $res = $info->{'INFO'}[0]; - } - else { - $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'}; - } - return $self->CurrentUser->loc( $res ); -} - -sub ColumnInfo { - my $self = shift; - my $column = shift; - - return $self->{'column_info'}{$column}; -} - -sub ColumnsList { - my $self = shift; - return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} } - keys %{ $self->{'column_info'} || {} }; -} - sub SetupGroupings { my $self = shift; my %args = ( @@ -713,108 +243,14 @@ sub SetupGroupings { $self->{'_sql_current_user_can_see_applied'} = 1 } - - %GROUPINGS = @GROUPINGS unless keys %GROUPINGS; - - my $i = 0; - - my @group_by = grep defined && length, - ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'}); - @group_by = ('Status') unless @group_by; - - my $distinct_results = 1; - foreach my $e ( splice @group_by ) { - unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) { - RT->Logger->error("'$e' is not a valid grouping for reports; skipping"); - next; - } - my ($key, $subkey) = split /(?_FieldToFunction( KEY => $key, SUBKEY => $subkey ) }; - $e->{'TYPE'} = 'grouping'; - $e->{'INFO'} = $self->_GroupingType($key); - $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} }; - $e->{'POSITION'} = $i++; - if ( my $distinct = $e->{'META'}{Distinct} ) { - if ( ref($distinct) eq 'CODE' ) { - $distinct_results = 0 unless $distinct->( $self, KEY => $key, SUBKEY => $subkey ); - } - } - else { - $distinct_results = 0; - } - push @group_by, $e; - } - $self->{_distinct_results} = $distinct_results; - - $self->GroupBy( map { { - ALIAS => $_->{'ALIAS'}, - FIELD => $_->{'FIELD'}, - FUNCTION => $_->{'FUNCTION'}, - } } @group_by ); - - my %res = (Groups => [], Functions => []); - my %column_info; - - foreach my $group_by ( @group_by ) { - $group_by->{'NAME'} = $self->Column( %$group_by ); - $column_info{ $group_by->{'NAME'} } = $group_by; - push @{ $res{'Groups'} }, $group_by->{'NAME'}; - } - - %STATISTICS = @STATISTICS unless keys %STATISTICS; - - my @function = grep defined && length, - ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'}); - push @function, 'COUNT' unless @function; - foreach my $e ( @function ) { - $e = { - TYPE => 'statistic', - KEY => $e, - INFO => $STATISTICS{ $e }, - META => $STATISTICS_META{ $STATISTICS{ $e }[1] }, - POSITION => $i++, - }; - unless ( $e->{'INFO'} && $e->{'META'} ) { - $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report"); - $e->{'FUNCTION'} = 'NULL'; - $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' ); - } - elsif ( $e->{'META'}{'Function'} ) { - my $code = $self->FindImplementationCode( $e->{'META'}{'Function'} ); - unless ( $code ) { - $e->{'FUNCTION'} = 'NULL'; - $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' ); - } - elsif ( $e->{'META'}{'SubValues'} ) { - my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] ); - $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++; - while ( my ($k, $v) = each %tmp ) { - $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v ); - @{ $e->{'MAP'}{ $k } }{'FUNCTION', 'ALIAS', 'FIELD'} = - @{ $v }{'FUNCTION', 'ALIAS', 'FIELD'}; - } - } - else { - my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] ); - $e->{'NAME'} = $self->Column( %tmp ); - @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'}; - } - } - elsif ( $e->{'META'}{'Calculate'} ) { - $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++; - } - push @{ $res{'Functions'} }, $e->{'NAME'}; - $column_info{ $e->{'NAME'} } = $e; - } - - $self->{'column_info'} = \%column_info; + my %res = $self->SUPER::SetupGroupings(%args); if ($args{Query} - && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $column_info{$_} } @{ $res{Groups} } ) + && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } ) || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ } - values %column_info ) + values %{ $self->{column_info} } ) || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} } - values %column_info ) ) + values %{ $self->{column_info} } ) ) ) { # Need to do the groupby/calculation at Perl level @@ -827,13 +263,6 @@ sub SetupGroupings { return %res; } -=head2 _DoSearch - -Subclass _DoSearch from our parent so we can go through and add in empty -columns if it makes sense - -=cut - sub _DoSearch { my $self = shift; @@ -851,7 +280,6 @@ sub _DoSearch { my $bh = $ticket->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $ticket->SLA }{BusinessHours} : ''; my @keys; - my $max = 1; my @extra_keys; my %css_class; for my $group ( @groups ) { @@ -863,7 +291,7 @@ sub _DoSearch { if ( my $obj = $ticket->$method ) { if ( $group->{INFO} eq 'Date' ) { if ( $obj->Unix > 0 ) { - $value = $obj->Strftime( $GROUPINGS_META{Date}{StrftimeFormat}{ $group->{SUBKEY} }, + $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} }, Timezone => 'user' ); } else { @@ -1152,41 +580,9 @@ sub _DoSearch { } $self->SUPER::_DoSearch( @_ ); - if ( $self->{'must_redo_search'} ) { - $RT::Logger->crit( -"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows" - ); - } - else { - $self->PostProcessRecords; - } -} - -=head2 _FieldToFunction FIELD - -Returns a tuple of the field or a database function to allow grouping on that -field. - -=cut - -sub _FieldToFunction { - my $self = shift; - my %args = (@_); - - $args{'FIELD'} ||= $args{'KEY'}; - - my $meta = $GROUPINGS_META{ $self->_GroupingType( $args{'KEY'} ) }; - return ('FUNCTION' => 'NULL') unless $meta; - - return %args unless $meta->{'Function'}; - - my $code = $self->FindImplementationCode( $meta->{'Function'} ); - return ('FUNCTION' => 'NULL') unless $code; - - return $code->( $self, %args ); + $self->_PostSearch(); } - # Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we # don't want. sub Next { @@ -1195,649 +591,15 @@ sub Next { } -sub NewItem { - my $self = shift; - my $res = RT::Report::Tickets::Entry->new($self->CurrentUser); - $res->{'report'} = $self; - weaken $res->{'report'}; - return $res; -} - -# This is necessary since normally NewItem (above) is used to intuit the -# correct class. However, since we're abusing a subclass, it's incorrect. sub _RoleGroupClass { "RT::Ticket" } sub _SingularClass { "RT::Report::Tickets::Entry" } -sub SortEntries { - my $self = shift; - - $self->_DoSearch if $self->{'must_redo_search'}; - return unless $self->{'items'} && @{ $self->{'items'} }; - - my @groups = - grep $_->{'TYPE'} eq 'grouping', - map $self->ColumnInfo($_), - $self->ColumnsList; - return unless @groups; - - my @SORT_OPS; - my $by_multiple = sub ($$) { - for my $f ( @SORT_OPS ) { - my $r = $f->($_[0], $_[1]); - return $r if $r; - } - }; - my @data = map [$_], @{ $self->{'items'} }; - - for ( my $i = 0; $i < @groups; $i++ ) { - my $group_by = $groups[$i]; - my $idx = $i+1; - - my $order = $group_by->{'META'}{Sort} || 'label'; - my $method = $order =~ /label$/ ? 'LabelValue' : 'RawValue'; - - unless ($order =~ /^numeric/) { - # Traverse the values being used for labels. - # If they all look like numbers or undef, flag for a numeric sort. - my $looks_like_number = 1; - foreach my $item (@data){ - my $label = $item->[0]->$method($group_by->{'NAME'}); - - $looks_like_number = 0 - unless (not defined $label) - or Scalar::Util::looks_like_number( $label ); - } - $order = "numeric $order" if $looks_like_number; - } - - if ( $order eq 'label' ) { - push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] }; - $method = 'LabelValue'; - } - elsif ( $order eq 'numeric label' ) { - my $nv = $self->loc("(no value)"); - # Sort the (no value) elements first, by comparing for them - # first, and falling back to a numeric sort on all other - # values. - push @SORT_OPS, sub { - (($_[0][$idx] ne $nv) <=> ($_[1][$idx] ne $nv)) - || ( $_[0][$idx] <=> $_[1][$idx] ) }; - $method = 'LabelValue'; - } - elsif ( $order eq 'raw' ) { - push @SORT_OPS, sub { ($_[0][$idx]//'') cmp ($_[1][$idx]//'') }; - $method = 'RawValue'; - } - elsif ( $order eq 'numeric raw' ) { - push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] }; - $method = 'RawValue'; - } - elsif ( $order eq 'duration' ) { - push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] }; - $method = 'DurationValue'; - } else { - $RT::Logger->error("Unknown sorting function '$order'"); - next; - } - $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data; - } - $self->{'items'} = [ - map $_->[0], - sort $by_multiple @data - ]; -} - -sub PostProcessRecords { - my $self = shift; - - my $info = $self->{'column_info'}; - foreach my $column ( values %$info ) { - next unless $column->{'TYPE'} eq 'statistic'; - if ( $column->{'META'}{'Calculate'} ) { - $self->CalculatePostFunction( $column ); - } - elsif ( $column->{'META'}{'SubValues'} ) { - $self->MapSubValues( $column ); - } - } -} - -sub CalculatePostFunction { - my $self = shift; - my $info = shift; - - my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} ); - unless ( $code ) { - # TODO: fill in undefs - return; - } - - my $column = $info->{'NAME'}; - - my $base_query = $self->Query; - foreach my $item ( @{ $self->{'items'} } ) { - $item->{'values'}{ lc $column } = $code->( - $self, - Query => join( - ' AND ', map "($_)", grep defined && length, $base_query, $item->Query, - ), - ); - $item->{'fetched'}{ lc $column } = 1; - } -} - -sub MapSubValues { - my $self = shift; - my $info = shift; - - my $to = $info->{'NAME'}; - my $map = $info->{'MAP'}; - - foreach my $item ( @{ $self->{'items'} } ) { - my $dst = $item->{'values'}{ lc $to } = { }; - while (my ($k, $v) = each %{ $map } ) { - $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} }; - # This mirrors the logic in RT::Record::__Value When that - # ceases tp use the UTF-8 flag as a character/byte - # distinction from the database, this can as well. - utf8::decode( $dst->{ $k } ) - if defined $dst->{ $k } - and not utf8::is_utf8( $dst->{ $k } ); - delete $item->{'fetched'}{ lc $v->{'NAME'} }; - } - $item->{'fetched'}{ lc $to } = 1; - } -} - -sub GenerateDateFunction { - my $self = shift; - my %args = @_; - - my $tz; - if ( RT->Config->Get('ChartsTimezonesInDB') ) { - my $to = $self->CurrentUser->UserObj->Timezone - || RT->Config->Get('Timezone'); - $tz = { From => 'UTC', To => $to } - if $to && lc $to ne 'utc'; - } - - $args{'FUNCTION'} = $RT::Handle->DateTimeFunction( - Type => $args{'SUBKEY'}, - Field => $self->NotSetDateToNullFunction, - Timezone => $tz, - ); - return %args; -} - -sub GenerateCustomFieldFunction { - my $self = shift; - my %args = @_; - - my ($name) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ ); - my $cf = RT::CustomField->new( $self->CurrentUser ); - $cf->Load($name); - unless ( $cf->id ) { - $RT::Logger->error("Couldn't load CustomField #$name"); - @args{qw(FUNCTION FIELD)} = ('NULL', undef); - } else { - my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf); - @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content'); - } - return %args; -} - -sub GenerateUserFunction { - my $self = shift; - my %args = @_; - - my $column = $args{'SUBKEY'} || 'Name'; - my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"} - ||= $self->Join( - TYPE => 'LEFT', - ALIAS1 => 'main', - FIELD1 => $args{'FIELD'}, - TABLE2 => 'Users', - FIELD2 => 'id', - ); - @args{qw(ALIAS FIELD)} = ($u_alias, $column); - return %args; -} - -sub GenerateWatcherFunction { - my $self = shift; - my %args = @_; - - my $type = $args{'FIELD'}; - $type = '' if $type eq 'Watcher'; - - my $single_role; - - if ( $type =~ s!^CustomRole\.\{(\d+)\}!RT::CustomRole-$1! ) { - my $id = $1; - my $cr = RT::CustomRole->new( $self->CurrentUser ); - $cr->Load($id); - $single_role = 1 if $cr->MaxValues; - } - - my $column = $single_role ? $args{'SUBKEY'} || 'Name' : 'id'; - - my $alias = $self->{"_sql_report_watcher_alias_$type"}; - unless ( $alias ) { - my $groups = $self->_RoleGroupsJoin(Name => $type); - my $group_members = $self->Join( - TYPE => 'LEFT', - ALIAS1 => $groups, - FIELD1 => 'id', - TABLE2 => 'GroupMembers', - FIELD2 => 'GroupId', - ENTRYAGGREGATOR => 'AND', - ); - $alias = $self->Join( - TYPE => 'LEFT', - ALIAS1 => $group_members, - FIELD1 => 'MemberId', - TABLE2 => $single_role ? 'Users' : 'Principals', - FIELD2 => 'id', - ); - $self->{"_sql_report_watcher_alias_$type"} = $alias; - } - @args{qw(ALIAS FIELD)} = ($alias, $column); - - return %args; -} - -sub DurationAsString { - my $self = shift; - my %args = @_; - my $v = $args{'VALUE'}; - my $max_unit = $args{INFO} && ref $args{INFO}[-1] && $args{INFO}[-1]{business_time} ? 'hour' : 'year'; - my $format = $args{FORMAT} || 'text'; - - my $css_class; - if ( $format eq 'html' - && $self->can('__Value') - && $args{INFO} - && ref $args{INFO}[-1] - && $args{INFO}[-1]{business_time} ) - { - - # 1 means business hours in SLA, its css is already generated and saved in _css_class. - if ( $args{INFO}[-1]{business_time} eq '1' ) { - my $style = $self->__Value('_css_class'); - my $field; - if ( $args{INFO}[1] =~ /^CustomDateRange/ ) { - $field = $args{INFO}[-2]; - } - elsif ( $args{INFO}[1] =~ /^DateTimeInterval/ ) { - $field = join ' to ', $args{INFO}[-3], $args{INFO}[-2]; - } - - $css_class = $style->{$field} if $style && $field; - } - else { - $css_class = 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $args{INFO}[-1]{business_time} ) - } - } - - unless ( ref $v ) { - my $value; - if ( defined $v && length $v ) { - $value = RT::Date->new( $self->CurrentUser )->DurationAsString( - $v, - Show => 3, - Short => 1, - MaxUnit => $max_unit, - ); - } - else { - $value = $self->loc("(no value)"); - } - - if ( $format eq 'html' ) { - RT::Interface::Web::EscapeHTML(\$value); - return $value unless $css_class; - return qq{$value}; - } - else { - return $value; - } - - } - - my $date = RT::Date->new( $self->CurrentUser ); - my %res = %$v; - foreach my $e ( values %res ) { - $e = $date->DurationAsString( $e, Short => 1, Show => 3, MaxUnit => $max_unit ) - if defined $e && length $e; - $e = $self->loc("(no value)") unless defined $e && length $e; - } - - if ( $format eq 'html' ) { - for my $key ( keys %res ) { - RT::Interface::Web::EscapeHTML(\$res{$key}); - next unless $css_class; - $res{$key} = qq{$res{$key}}; - } - } - return \%res; -} - -sub LabelValueCode { - my $self = shift; - my $name = shift; - - my $display = $self->ColumnInfo( $name )->{'META'}{'Display'}; - return undef unless $display; - return $self->FindImplementationCode( $display ); -} - - -sub FindImplementationCode { - my $self = shift; - my $value = shift; - my $silent = shift; - - my $code; - unless ( $value ) { - $RT::Logger->error("Value is not defined. Should be method name or code reference") - unless $silent; - return undef; - } - elsif ( !ref $value ) { - $code = $self->can( $value ); - unless ( $code ) { - $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" ) - unless $silent; - return undef; - } - } - elsif ( ref( $value ) eq 'CODE' ) { - $code = $value; - } - else { - $RT::Logger->error("$value is not method name or code reference") - unless $silent; - return undef; - } - return $code; -} - -sub Serialize { - my $self = shift; - - my %clone = %$self; -# current user, handle and column_info - delete @clone{'user', 'DBIxHandle', 'column_info'}; - $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ]; - $clone{'column_info'} = {}; - while ( my ($k, $v) = each %{ $self->{'column_info'} } ) { - $clone{'column_info'}{$k} = { %$v }; - delete $clone{'column_info'}{$k}{'META'}; - } - return \%clone; -} - -sub Deserialize { - my $self = shift; - my $data = shift; - - $self->CleanSlate; - %$self = (%$self, %$data); - - $self->{'items'} = [ - map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r } - @{ $self->{'items'} } - ]; - foreach my $e ( values %{ $self->{column_info} } ) { - $e->{'META'} = $e->{'TYPE'} eq 'grouping' - ? $GROUPINGS_META{ $e->{'INFO'} } - : $STATISTICS_META{ $e->{'INFO'}[1] } - } -} - - -sub FormatTable { - my $self = shift; - my %columns = @_; - - my (@head, @body, @footer); - - @head = ({ cells => []}); - foreach my $column ( @{ $columns{'Groups'} } ) { - push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) }; - } - - my $i = 0; - while ( my $entry = $self->Next ) { - $body[ $i ] = { even => ($i+1)%2, cells => [] }; - $i++; - } - @footer = ({ even => ++$i%2, cells => []}) if $self->{_distinct_results}; - - my $g = 0; - foreach my $column ( @{ $columns{'Groups'} } ) { - $i = 0; - my $last; - while ( my $entry = $self->Next ) { - my $value = $entry->LabelValue( $column, 'html' ); - if ( !$last || $last->{'value'} ne $value ) { - push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value }; - $last->{even} = $g++ % 2 - unless $column eq $columns{'Groups'}[-1]; - } - else { - $i++; - $last->{rowspan} = ($last->{rowspan}||1) + 1; - } - } - } - push @{ $footer[0]{'cells'} }, { - type => 'label', - value => $self->loc('Total'), - colspan => scalar @{ $columns{'Groups'} }, - } if $self->{_distinct_results}; - - my $pick_color = do { - my @colors = RT->Config->Get("ChartColors"); - sub { $colors[ $_[0] % @colors - 1 ] } - }; - - my $function_count = 0; - foreach my $column ( @{ $columns{'Functions'} } ) { - $i = 0; - - my $info = $self->ColumnInfo( $column ); - - my @subs = (''); - if ( $info->{'META'}{'SubValues'} ) { - @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->( - $self - ); - } - - my %total; - unless ( $info->{'META'}{'NoTotals'} ) { - while ( my $entry = $self->Next ) { - my $raw = $entry->RawValue( $column ) || {}; - $raw = { '' => $raw } unless ref $raw; - $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs; - } - @subs = grep $total{$_}, @subs - unless $info->{'META'}{'NoHideEmpty'}; - } - - my $label = $self->Label( $column ); - - unless (@subs) { - while ( my $entry = $self->Next ) { - push @{ $body[ $i++ ]{'cells'} }, { - type => 'value', - value => undef, - query => $entry->Query, - }; - } - push @{ $head[0]{'cells'} }, { - type => 'head', - value => $label, - rowspan => scalar @head, - color => $pick_color->(++$function_count), - }; - push @{ $footer[0]{'cells'} }, { type => 'value', value => undef } if $self->{_distinct_results}; - next; - } - - if ( @subs > 1 && @head == 1 ) { - $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} }; - } - - if ( @subs == 1 ) { - push @{ $head[0]{'cells'} }, { - type => 'head', - value => $label, - rowspan => scalar @head, - color => $pick_color->(++$function_count), - }; - } else { - push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs }; - push @{ $head[1]{'cells'} }, { type => 'head', value => $_, color => $pick_color->(++$function_count) } - foreach @subs; - } - - while ( my $entry = $self->Next ) { - my $query = $entry->Query; - my $value = $entry->LabelValue( $column, 'html' ) || {}; - $value = { '' => $value } unless ref $value; - foreach my $e ( @subs ) { - push @{ $body[ $i ]{'cells'} }, { - type => 'value', - value => $value->{ $e }, - query => $query, - }; - } - $i++; - } - - next unless $self->{_distinct_results}; - unless ( $info->{'META'}{'NoTotals'} ) { - my $total_code = $self->LabelValueCode( $column ); - foreach my $e ( @subs ) { - my $total = $total{ $e }; - $total = $total_code->( $self, %$info, VALUE => $total, FORMAT => 'html' ) - if $total_code; - push @{ $footer[0]{'cells'} }, { type => 'value', value => $total }; - } - } - else { - foreach my $e ( @subs ) { - push @{ $footer[0]{'cells'} }, { type => 'value', value => undef }; - } - } - } - - return thead => \@head, tbody => \@body, tfoot => \@footer; -} - -sub _CalculateTime { - my $self = shift; - my ( $type, $value, $current ) = @_; - - return $current unless defined $value; - - if ( $type eq 'SUM' ) { - $current += $value; - } - elsif ( $type eq 'AVG' ) { - $current ||= {}; - $current->{total} += $value; - $current->{count}++; - $current->{calculate} ||= sub { - my $item = shift; - return sprintf '%.0f', $item->{total} / $item->{count}; - }; - } - elsif ( $type eq 'MAX' ) { - $current = $value unless $current && $current > $value; - } - elsif ( $type eq 'MIN' ) { - $current = $value unless $current && $current < $value; - } - else { - RT->Logger->error("Unsupported type $type"); - } - return $current; -} - sub new { my $self = shift; $self->_SetupCustomDateRanges; return $self->SUPER::new(@_); } - -sub _SetupCustomDateRanges { - my $self = shift; - my %names; - - # Remove old custom date range groupings - for my $field ( grep {ref} @STATISTICS ) { - if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) { - $names{ $field->[2] } = 1; - } - } - - my ( @new_groupings, @new_statistics ); - while (@GROUPINGS) { - my $name = shift @GROUPINGS; - my $type = shift @GROUPINGS; - if ( !$names{$name} ) { - push @new_groupings, $name, $type; - } - } - - while (@STATISTICS) { - my $key = shift @STATISTICS; - my $info = shift @STATISTICS; - my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/; - unless ( $name && $names{$name} ) { - push @new_statistics, $key, $info; - } - } - - # Add new ones - my %ranges = RT::Ticket->CustomDateRanges; - for my $name ( sort keys %ranges ) { - my %extra_info; - my $spec = $ranges{$name}; - if ( ref $spec && $spec->{business_time} ) { - $extra_info{business_time} = $spec->{business_time}; - } - - push @new_groupings, $name => $extra_info{business_time} ? 'DurationInBusinessHours' : 'Duration'; - push @new_statistics, - ( - "ALL($name)" => [ "Summary of $name", 'CustomDateRangeAll', $name, \%extra_info ], - "SUM($name)" => [ "Total $name", 'CustomDateRange', 'SUM', $name, \%extra_info ], - "AVG($name)" => [ "Average $name", 'CustomDateRange', 'AVG', $name, \%extra_info ], - "MIN($name)" => [ "Minimum $name", 'CustomDateRange', 'MIN', $name, \%extra_info ], - "MAX($name)" => [ "Maximum $name", 'CustomDateRange', 'MAX', $name, \%extra_info ], - ); - } - - @GROUPINGS = @new_groupings; - @STATISTICS = @new_statistics; - %GROUPINGS = %STATISTICS = (); - - return 1; -} - -sub _GroupingType { - my $self = shift; - my $key = shift or return; - # keys for custom roles are like "CustomRole.{1}" - $key = 'CustomRole' if $key =~ /^CustomRole/; - return $GROUPINGS{$key}; -} - RT::Base->_ImportOverlays(); 1; diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm index 1dc357c10cb..dab19546db2 100644 --- a/lib/RT/Report/Tickets/Entry.pm +++ b/lib/RT/Report/Tickets/Entry.pm @@ -51,159 +51,9 @@ package RT::Report::Tickets::Entry; use warnings; use strict; -use base qw/RT::Record/; +use base qw/RT::Report::Entry/; -# XXX TODO: how the heck do we acl a report? -sub CurrentUserHasRight {1} - -=head2 LabelValue - -If you're pulling a value out of this collection and using it as a label, -you may want the "cleaned up" version. This includes scrubbing 1970 dates -and ensuring that dates are in local not DB timezones. - -=cut - -sub LabelValue { - my $self = shift; - my $name = shift; - my $format = shift || 'text'; - - my $raw = $self->RawValue( $name, @_ ); - if ( my $code = $self->Report->LabelValueCode( $name ) ) { - $raw = $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw, FORMAT => $format ); - return $self->loc('(no value)') unless defined $raw && length $raw; - return $raw; - } - - unless ( ref $raw ) { - return $self->loc('(no value)') unless defined $raw && length $raw; - return $self->loc($raw) if $self->Report->ColumnInfo( $name )->{'META'}{'Localize'}; - return $raw; - } else { - my $loc = $self->Report->ColumnInfo( $name )->{'META'}{'Localize'}; - my %res = %$raw; - if ( $loc ) { - $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res; - $_ = $self->loc($_) foreach values %res; - } - $_ = $self->loc('(no value)') foreach grep !defined || !length, values %res; - return \%res; - } -} - -sub RawValue { - return (shift)->__Value( @_ ); -} - -sub ObjectType { - return 'RT::Ticket'; -} - -sub CustomFieldLookupType { - RT::Ticket->CustomFieldLookupType -} - -sub Query { - my $self = shift; - - if ( my $ids = $self->{values}{ids} ) { - return join ' OR ', map "id=$_", @$ids; - } - - my @parts; - foreach my $column ( $self->Report->ColumnsList ) { - my $info = $self->Report->ColumnInfo( $column ); - next unless $info->{'TYPE'} eq 'grouping'; - - my $custom = $info->{'META'}{'Query'}; - if ( $custom and my $code = $self->Report->FindImplementationCode( $custom ) ) { - push @parts, $code->( $self, COLUMN => $column, %$info ); - } - else { - my $field = join '.', grep $_, $info->{KEY}, $info->{SUBKEY}; - my $value = $self->RawValue( $column ); - my $op = '='; - if ( defined $value ) { - if ( $info->{INFO} eq 'Watcher' && $info->{FIELD} eq 'id' ) { - - # convert id to name - my $princ = RT::Principal->new( $self->CurrentUser ); - $princ->Load($value); - $value = $princ->Object->Name if $princ->Object; - } - - unless ( $value =~ /^\d+$/ ) { - $value =~ s/(['\\])/\\$1/g; - $value = "'$value'"; - } - } - else { - ($op, $value) = ('IS', 'NULL'); - } - unless ( $field =~ /^[{}\w\.]+$/ ) { - $field =~ s/(['\\])/\\$1/g; - $field = "'$field'"; - } - push @parts, "$field $op $value"; - } - } - return () unless @parts; - return join ' AND ', map "($_)", grep defined && length, @parts; -} - -sub Report { - return $_[0]->{'report'}; -} - -sub DurationValue { - my $self = shift; - my $value = $self->__Value(@_); - - return 0 unless $value; - - my $number; - my $unit; - if ( $value =~ /([\d,]+)(?:s| second)/ ) { - $number = $1; - $unit = 1; - } - elsif ( $value =~ /([\d,]+)(?:m| minute)/ ) { - $number = $1; - $unit = $RT::Date::MINUTE; - } - elsif ( $value =~ /([\d,]+)(?:h| hour)/ ) { - $number = $1; - $unit = $RT::Date::HOUR; - } - elsif ( $value =~ /([\d,]+)(?:d| day)/ ) { - $number = $1; - $unit = $RT::Date::DAY; - } - elsif ( $value =~ /([\d,]+)(?:W| week)/ ) { - $number = $1; - $unit = $RT::Date::WEEK; - } - elsif ( $value =~ /([\d,]+)(?:M| month)/ ) { - $number = $1; - $unit = $RT::Date::MONTH; - } - elsif ( $value =~ /([\d,]+)(?:Y| year)/ ) { - $number = $1; - $unit = $RT::Date::YEAR; - } - else { - return -.1; # Mark "(no value)" as -1 so it comes before 0 - } - - $number =~ s!,!!g; - my $seconds = $number * $unit; - - if ( $value =~ /([<|>])/ ) { - $seconds += $1 eq '<' ? -1 : 1; - } - return $seconds; -} +sub ObjectType { 'RT::Ticket' } RT::Base->_ImportOverlays(); diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm new file mode 100644 index 00000000000..14a635b6f02 --- /dev/null +++ b/lib/RT/Report/Transactions.pm @@ -0,0 +1,127 @@ +# 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::Transactions; + +use base qw/RT::Report RT::Transactions/; +use RT::Report::Transactions::Entry; + +use strict; +use warnings; +use 5.010; + +our @GROUPINGS = ( + Creator => 'User', #loc_left_pair + Created => 'Date', #loc_left_pair +); + +# loc'able strings below generated with (s/loq/loc/): +# perl -MRT=-init -MRT::Report::Transactions -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Transactions::STATISTICS, 0, 2' +# +# loc("Transaction count") + +our @STATISTICS = ( + COUNT => [ 'Transaction count', 'Count', 'id' ], + "ALL(TimeTaken)" => [ "Summary of Time Taken", 'TimeAll', 'TimeTaken' ], + "SUM(TimeTaken)" => [ "Total Time Taken", 'Time', 'SUM', 'TimeTaken' ], + "AVG(TimeTaken)" => [ "Average Time Taken", 'Time', 'AVG', 'TimeTaken' ], + "MIN(TimeTaken)" => [ "Minimum Time Taken", 'Time', 'MIN', 'TimeTaken' ], + "MAX(TimeTaken)" => [ "Maximum Time Taken", 'Time', 'MAX', 'TimeTaken' ], +); + +sub SetupGroupings { + my $self = shift; + my %args = ( + Query => undef, + GroupBy => undef, + Function => undef, + @_ + ); + + # Unlike tickets, UseSQLForACLChecks is not supported in transactions, thus we need to iterate transactions first + # to filter by rights, which is implemented in RT::Transactions::AddRecord + if ( $args{'Query'} ) { + my $txns = RT::Transactions->new( $self->CurrentUser ); + # Currently we only support ticket transaction search. + $txns->FromSQL( "ObjectType='RT::Ticket' AND TicketType = 'ticket' AND ($args{'Query'})" ); + $txns->Columns('id'); + + my @match = (0); + while ( my $row = $txns->Next ) { + push @match, $row->id; + } + + $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 ); + } + + return $self->SUPER::SetupGroupings(%args); +} + +sub _DoSearch { + my $self = shift; + + # Reset the unnecessary default order by(created and id, defined in RT::Transactions::_Init), otherwise Pg will + # error out: column "main.created" must appear in the GROUP BY clause or be used in an aggregate function; while + # Oracle will error out: ORA-00979: not a GROUP BY expression + $self->OrderByCols(); + + $self->SUPER::_DoSearch(@_); + $self->_PostSearch(); +} + +sub _RoleGroupClass {"RT::Transaction"} +sub _SingularClass {"RT::Report::Transactions::Entry"} + +RT::Base->_ImportOverlays(); + +1; diff --git a/lib/RT/Report/Transactions/Entry.pm b/lib/RT/Report/Transactions/Entry.pm new file mode 100644 index 00000000000..fd3f8aad9e9 --- /dev/null +++ b/lib/RT/Report/Transactions/Entry.pm @@ -0,0 +1,60 @@ +# 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::Transactions::Entry; + +use warnings; +use strict; + +use base qw/RT::Report::Entry/; + +sub ObjectType { 'RT::Transaction' } + +RT::Base->_ImportOverlays(); + +1; diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm index c134f176016..bf9edc8baaf 100644 --- a/lib/RT/SearchBuilder.pm +++ b/lib/RT/SearchBuilder.pm @@ -169,8 +169,15 @@ sub _OrderByCF { ENTRYAGGREGATOR => 'AND' ); - return { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' }, - { %$row, ALIAS => $ocfvs, FIELD => 'Content' }; + return { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' }, + { + %$row, + ALIAS => $ocfvs, + FIELD => 'Content', + blessed $cf && $cf->IsNumeric + ? ( FUNCTION => $self->_CastToDecimal('Content') ) + : () + }; } sub OrderByCols { @@ -267,6 +274,20 @@ sub RecordClass { $_[0]->_SingularClass } +=head2 ReportClass + +Returns report class name of this collection. E.g. report class of RT::Tickets +is RT::Report::Tickets + +=cut + +sub ReportClass { + my $self = shift; + my $class = ref($self) || $self; + $class =~ s/(?<=^RT::)/Report::/ or die "Cannot deduce ReportClass for $class"; + return $class; +} + =head2 RegisterCustomFieldJoin Takes a pair of arguments, the first a class name and the second a callback @@ -529,9 +550,20 @@ sub _LimitCustomField { my $fix_op = sub { + my %args = @_; + + if ( $args{'FIELD'} eq 'Content' + && blessed $cf + && $cf->IsNumeric + && ( !$args{QUOTEVALUE} || Scalar::Util::looks_like_number($args{'VALUE'}) ) ) + { + $args{QUOTEVALUE} = 0; + $args{FUNCTION} = $self->_CastToDecimal( "$args{ALIAS}.$args{FIELD}" ); + return %args; + } + return @_ unless RT->Config->Get('DatabaseType') eq 'Oracle'; - my %args = @_; return %args unless $args{'FIELD'} eq 'LargeContent'; my $op = $args{'OPERATOR'}; @@ -827,17 +859,18 @@ sub _LimitCustomField { ); } else { # Otherwise, go looking at the Content - $self->Limit( + $self->Limit( $fix_op->( %args, ALIAS => $ocfvalias, FIELD => 'Content', OPERATOR => $op, VALUE => $value, CASESENSITIVE => 0, - ); + ) ); } - if (!$value_is_long and $op eq "=") { + if ( ( blessed($cf) and $cf->IsNumeric ) or ( !$value_is_long and $op eq "=" ) ) { + # Skip LargeContent comparison for numeric values. # Doesn't matter what LargeContent contains, as it cannot match # the short value. } elsif (!$value_is_long and $op =~ /^(!=|<>)$/) { @@ -1155,6 +1188,23 @@ sub CurrentUserCanSeeAll { return $self->CurrentUser->HasRight( Right => 'SuperUser', Object => RT->System ) ? 1 : 0; } +sub _CastToDecimal { + my $self = shift; + my $field = shift or return; + + my $db_type = RT->Config->Get('DatabaseType'); + if ( $db_type eq 'Oracle' ) { + return "TO_NUMBER($field)"; + } + elsif ( $db_type eq 'mysql' ) { + # mysql's CAST decimal requires precision specification, which we don't know. + return "($field+0)"; + } + else { + return "CAST($field AS DECIMAL)"; + } +} + RT::Base->_ImportOverlays(); 1; diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm index b9d0fbae042..c6e8c3c11a6 100644 --- a/lib/RT/Tickets.pm +++ b/lib/RT/Tickets.pm @@ -1713,7 +1713,14 @@ sub OrderByCols { ENTRYAGGREGATOR => 'AND' ); push @res, { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' }, - { %$row, ALIAS => $ocfvs, FIELD => 'Content' }; + { + %$row, + ALIAS => $ocfvs, + FIELD => 'Content', + blessed $cf && $cf->IsNumeric + ? ( FUNCTION => $self->_CastToDecimal('Content') ) + : () + }; } else { RT->Logger->warning("Couldn't load user custom field $cf_name"); @@ -3597,28 +3604,39 @@ sub _parser { $value = "main.$value" if $class eq 'RT::Tickets' && $value =~ /^\w+$/; if ( $class eq 'RT::ObjectCustomFieldValues' ) { + my $cast_to; + if ( $meta->[0] eq 'CUSTOMFIELD' ) { + my ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $subkey ); + if ( $cf && $cf->IsNumeric ) { + $cast_to = 'DECIMAL'; + } + } + if ( RT->Config->Get('DatabaseType') eq 'Pg' ) { - my $cast_to; - if ($subkey) { + if ( !$cast_to ) { + if ($subkey) { - # like Requestor.id - if ( $subkey eq 'id' ) { - $cast_to = 'INTEGER'; - } - } - elsif ( my $meta = $self->RecordClass->_ClassAccessible->{$key} ) { - if ( $meta->{is_numeric} ) { - $cast_to = 'INTEGER'; + # like Requestor.id + if ( $subkey eq 'id' ) { + $cast_to = 'INTEGER'; + } } - elsif ( $meta->{type} eq 'datetime' ) { - $cast_to = 'TIMESTAMP'; + elsif ( my $meta = $self->RecordClass->_ClassAccessible->{$key} ) { + if ( $meta->{is_numeric} ) { + $cast_to = 'INTEGER'; + } + elsif ( $meta->{type} eq 'datetime' ) { + $cast_to = 'TIMESTAMP'; + } } } - $value = "CAST($value AS $cast_to)" if $cast_to; } elsif ( RT->Config->Get('DatabaseType') eq 'Oracle' ) { - if ($subkey) { + if ( $cast_to && $cast_to eq 'DECIMAL' ) { + $value = "TO_NUMBER($value)"; + } + elsif ($subkey) { # like Requestor.id if ( $subkey eq 'id' ) { diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch index a86701b48e2..2202f40406d 100644 --- a/share/html/Elements/ShowSearch +++ b/share/html/Elements/ShowSearch @@ -119,6 +119,7 @@ if ($SavedSearch) { if ( $SearchArg->{'SearchType'} eq 'Chart' ) { $SearchArg->{'SavedChartSearchId'} ||= $SavedSearch; + $class = $SearchArg->{Class} if $SearchArg->{Class}; } # XXX: dispatch to different handler here diff --git a/share/html/Search/Chart b/share/html/Search/Chart index d5cead18243..6a653773fcc 100644 --- a/share/html/Search/Chart +++ b/share/html/Search/Chart @@ -54,6 +54,7 @@ $ChartStyle => 'bar+table+sql' @ChartFunction => 'COUNT' $Width => undef $Height => undef +$Class => 'RT::Tickets' <%init> use GD; @@ -101,8 +102,9 @@ my $plot_error = sub { $m->comp( 'SELF:Plot', plot => $plot, %ARGS ); }; -use RT::Report::Tickets; -my $report = RT::Report::Tickets->new( $session{'CurrentUser'} ); +my $report_class = ( $Class || 'RT::Tickets' )->ReportClass; +RT::StaticUtil::RequireModule($report_class) or Abort( loc("Couldn't load [_1]", $report_class) ); +my $report = $report_class->new( $session{'CurrentUser'} ); my %columns; if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) { diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html index cd15b50fa7f..76a14842b7a 100644 --- a/share/html/Search/Chart.html +++ b/share/html/Search/Chart.html @@ -46,9 +46,13 @@ %# %# END BPS TAGGED BLOCK }}} <%init> +my $report_class = ( $Class || 'RT::Tickets' )->ReportClass; +RT::StaticUtil::RequireModule($report_class) or Abort( loc("Couldn't load [_1]", $report_class) ); +my $report = $report_class->new( $session{'CurrentUser'} ); + my $default_value = { Query => 'id > 0', - GroupBy => ['Status'], + GroupBy => [ $report->DefaultGroupBy ], ChartStyle => 'bar+table+sql', ChartFunction => ['COUNT'], }; @@ -57,7 +61,7 @@ my $title = loc( "Grouped search results"); -my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height ExtraQueryParams), grep $_, @ExtraQueryParams ); +my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height Class ExtraQueryParams), grep $_, @ExtraQueryParams ); my $saved_search = $m->comp( '/Widgets/SavedSearch:new', SearchType => 'Chart', SearchFields => [@search_fields], @@ -147,6 +151,7 @@
+ % if ( $query{ExtraQueryParams} ) { % for my $input ( ref $query{ExtraQueryParams} eq 'ARRAY' ? @{$query{ExtraQueryParams}} : $query{ExtraQueryParams} ) { @@ -158,13 +163,14 @@ % } <&| /Widgets/TitleBox, title => loc('Group by'), class => "chart-group-by" &> -
<% loc('Group tickets by') %> +
<% loc('Group [_1] by', loc(lc $Class->Table)) %> <& Elements/SelectGroupBy, Name => 'GroupBy', Query => $query{Query}, Default => $query{'GroupBy'}[0], Stacked => $query{'GroupBy'}[0] eq ($query{StackedGroupBy} // '') ? 1 : 0, StackedId => 'StackedGroupBy-1', + Class => $Class, &>
<% loc('and then') %> @@ -175,6 +181,7 @@ ShowEmpty => 1, Stacked => $query{'GroupBy'}[1] && ($query{'GroupBy'}[1] eq ($query{StackedGroupBy} // '')) ? 1 : 0, StackedId => 'StackedGroupBy-2', + Class => $Class, &>
<% loc('and then') %> @@ -185,19 +192,20 @@ ShowEmpty => 1, Stacked => $query{'GroupBy'}[2] && ($query{'GroupBy'}[2] eq ($query{StackedGroupBy} // '')) ? 1 : 0, StackedId => 'StackedGroupBy-3', + Class => $Class, &>
<&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
<% loc('Calculate values of') %> - <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0] &> + <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0], Class => $Class, &>
<% loc('and then') %> - <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1 &> + <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1, Class => $Class, &>
<% loc('and then') %> - <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1 &> + <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1, Class => $Class, &>
@@ -330,7 +338,7 @@
@@ -340,4 +348,5 @@ <%ARGS> @ExtraQueryParams => () +$Class => 'RT::Tickets' diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart index a6cd9e0ada9..3f0f889c762 100644 --- a/share/html/Search/Elements/Chart +++ b/share/html/Search/Elements/Chart @@ -50,11 +50,13 @@ $Query => "id > 0" @GroupBy => () $ChartStyle => 'bar+table+sql' @ChartFunction => 'COUNT' +$Class => 'RT::Tickets' <%init> -use RT::Report::Tickets; -my $report = RT::Report::Tickets->new( $session{'CurrentUser'} ); +my $report_class = ( $Class || 'RT::Tickets' )->ReportClass; +RT::StaticUtil::RequireModule($report_class) or Abort( loc("Couldn't load [_1]", $report_class) ); +my $report = $report_class->new( $session{'CurrentUser'} ); my %columns = $report->SetupGroupings( Query => $Query, diff --git a/share/html/Search/Elements/ChartTable b/share/html/Search/Elements/ChartTable index 694d297b02e..175a3c56809 100644 --- a/share/html/Search/Elements/ChartTable +++ b/share/html/Search/Elements/ChartTable @@ -48,6 +48,7 @@ <%ARGS> %Table => () $Query => 'id > 0' +$Class => 'RT::Tickets' <%INIT> diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches index c9db81f1b9e..7068315ec6b 100644 --- a/share/html/Search/Elements/EditSearches +++ b/share/html/Search/Elements/EditSearches @@ -123,7 +123,7 @@
<&|/l&>Load saved search:
-<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type &> +<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type, Class => $Class &>
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction index 7a794fea4c3..85f09a6e840 100644 --- a/share/html/Search/Elements/SelectChartFunction +++ b/share/html/Search/Elements/SelectChartFunction @@ -53,6 +53,7 @@ my $in_optgroup = ""; while ( my ($value, $display) = splice @functions, 0, 2 ) { my $optgroup = $value =~ /\((.+)\)$/ ? $1 : $display; + $optgroup = 'Custom field' if $optgroup =~ /^CF\./; if ($in_optgroup ne $optgroup) { $m->out("\n") if $in_optgroup; @@ -72,8 +73,12 @@ while ( my ($value, $display) = splice @functions, 0, 2 ) { $Name => 'ChartFunction' $Default => 'COUNT' $ShowEmpty => 0 +$Class => $Class +$Query => '' <%INIT> -my @functions = RT::Report::Tickets->Statistics; -$Default = '' unless defined $Default; +my $report_class = ( $Class || 'RT::Tickets' )->ReportClass; +RT::StaticUtil::RequireModule($report_class) or Abort( loc("Couldn't load [_1]", $report_class) ); +my @functions + = map { ref($_) ? $_->[0] : $_ } $report_class->new( $session{CurrentUser} )->Statistics( Query => $Query ); diff --git a/share/html/Search/Elements/SelectGroupBy b/share/html/Search/Elements/SelectGroupBy index e5c7e77c130..b704f1f86b8 100644 --- a/share/html/Search/Elements/SelectGroupBy +++ b/share/html/Search/Elements/SelectGroupBy @@ -47,11 +47,12 @@ %# END BPS TAGGED BLOCK }}} <%args> $Name => 'GroupBy' -$Default => 'Status' +$Default => '' $Query => '' $ShowEmpty => 0 $Stacked => 0 $StackedId => "Stacked$Name" +$Class => 'RT::Tickets'