Mailing List Archive

rt branch 5.0/txn-search-chart created. rt-5.0.3-334-gace552ee51
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "rt".

The branch, 5.0/txn-search-chart has been created
at ace552ee51db4d75e72ed6fbe71dd5679a0eb8b4 (commit)

- Log -----------------------------------------------------------------
commit ace552ee51db4d75e72ed6fbe71dd5679a0eb8b4
Author: sunnavy <sunnavy@bestpractical.com>
Date: Wed Aug 3 05:06:47 2022 +0800

Test numeric custom field calculations in search charts

diff --git a/t/charts/calculate-numeric-cf.t b/t/charts/calculate-numeric-cf.t
new file mode 100644
index 0000000000..2f61d59eb1
--- /dev/null
+++ b/t/charts/calculate-numeric-cf.t
@@ -0,0 +1,174 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+use RT::Ticket;
+use RT::Report::Tickets;
+
+my $q = RT::Test->load_or_create_queue( Name => 'General' );
+my $cost = RT::Test->load_or_create_custom_field( Name => 'Cost', Type => 'FreeformSingle', Queue => $q->Id );
+my $cost_id = $cost->Id;
+
+{
+ no warnings 'redefine';
+ use RT::CustomField;
+ *RT::CustomField::IsNumeric = sub {
+ my $self = shift;
+ return $self->Name eq 'Cost' ? 1 : 0;
+ };
+
+ # Get around Pg 14's trailing 0 format like 25.000
+ *RT::Report::_CustomFieldNumericPrecision = sub { 0 };
+}
+
+my @tickets = RT::Test->create_tickets(
+ { Subject => 'test' },
+ { Status => 'new', 'CustomField-' . $cost->Id => 10 },
+ { Status => 'open', 'CustomField-' . $cost->Id => 15 },
+ { Status => 'new', 'CustomField-' . $cost->Id => 40 },
+);
+
+my $report = RT::Report::Tickets->new( RT->SystemUser );
+my %columns = $report->SetupGroupings(
+ Query => 'Queue = ' . $q->id,
+ GroupBy => ['Status'],
+ Function => ["ALL(CF.$cost_id)"],
+);
+$report->SortEntries;
+
+my @colors = RT->Config->Get("ChartColors");
+my $expected = {
+ 'thead' => [.
+ {
+ 'cells' => [.
+ {
+ 'rowspan' => 2,
+ 'type' => 'head',
+ 'value' => 'Status'
+ },
+ {
+ 'colspan' => 4,
+ 'type' => 'head',
+ 'value' => 'Summary of Cost'
+ }
+ ]
+ },
+ {
+ 'cells' => [
+ {
+ 'color' => $colors[0],
+ 'type' => 'head',
+ 'value' => 'Minimum'
+ },
+ {
+ 'color' => $colors[1],
+ 'type' => 'head',
+ 'value' => 'Average'
+ },
+ {
+ 'color' => $colors[2],
+ 'type' => 'head',
+ 'value' => 'Maximum'
+ },
+ {
+ 'color' => $colors[3],
+ 'type' => 'head',
+ 'value' => 'Total'
+ }
+ ]
+ }
+ ],
+ 'tbody' => [.
+ {
+ 'cells' => [.
+ {
+ 'type' => 'label',
+ 'value' => 'new'
+ },
+ {
+ 'query' => '(Status = \'new\')',
+ 'type' => 'value',
+ 'value' => 10
+ },
+ {
+ 'query' => '(Status = \'new\')',
+ 'type' => 'value',
+ 'value' => 25
+ },
+ {
+ 'query' => '(Status = \'new\')',
+ 'type' => 'value',
+ 'value' => 40
+ },
+ {
+ 'query' => '(Status = \'new\')',
+ 'type' => 'value',
+ 'value' => 50
+ }
+ ],
+ 'even' => 1
+ },
+ {
+ 'cells' => [.
+ {
+ 'type' => 'label',
+ 'value' => 'open'
+ },
+ {
+ 'query' => '(Status = \'open\')',
+ 'type' => 'value',
+ 'value' => 15
+ },
+ {
+ 'query' => '(Status = \'open\')',
+ 'type' => 'value',
+ 'value' => 15
+ },
+ {
+ 'query' => '(Status = \'open\')',
+ 'type' => 'value',
+ 'value' => 15
+ },
+ {
+ 'query' => '(Status = \'open\')',
+ 'type' => 'value',
+ 'value' => 15
+ }
+ ],
+ 'even' => 0
+ }
+ ],
+ 'tfoot' => [.
+ {
+ 'cells' => [.
+ {
+ 'colspan' => 1,
+ 'type' => 'label',
+ 'value' => 'Total'
+ },
+ {
+ 'type' => 'value',
+ 'value' => 25
+ },
+ {
+ 'type' => 'value',
+ 'value' => 40
+ },
+ {
+ 'type' => 'value',
+ 'value' => 55
+ },
+ {
+ 'type' => 'value',
+ 'value' => 65
+ }
+ ],
+ 'even' => 1
+ }
+ ],
+
+};
+my %table = $report->FormatTable(%columns);
+is_deeply( \%table, $expected, "numeric custom field table" );
+
+done_testing;

commit f8fbca85776fb0133288c8285a87b38dcf5bec0e
Author: sunnavy <sunnavy@bestpractical.com>
Date: Tue Aug 2 22:51:51 2022 +0800

Support to calculate numeric custom fields in search charts

diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index 019bc00bb8..bf2316c28d 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -403,6 +403,41 @@ our %STATISTICS_META = (
},
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 {
@@ -458,8 +493,9 @@ sub IsValidGrouping {
}

sub Statistics {
- my $self = shift;
- return map { ref($_)? $_->[0] : $_ } $self->_Statistics;
+ my $self = shift;
+ my @items = $self->_Statistics;
+ return @items, $self->_NumericCustomFields(@_);
}

sub Label {
@@ -555,8 +591,7 @@ sub SetupGroupings {
push @{ $res{'Groups'} }, $group_by->{'NAME'};
}

- my %statistics = $self->_Statistics;
-
+ my %statistics = $self->Statistics(%args);
my @function = grep defined && length,
ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
push @function, 'COUNT' unless @function;
@@ -1193,6 +1228,45 @@ sub _SetupCustomDateRanges {
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;
@@ -1268,6 +1342,9 @@ sub NewItem {
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();

diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index 03d9bab39f..225f1a80b8 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("</optgroup>\n") if $in_optgroup;

@@ -73,9 +74,11 @@ $Name => 'ChartFunction'
$Default => 'COUNT'
$ShowEmpty => 0
$Class => $Class
+$Query => ''
</%ARGS>
<%INIT>
my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
RT::StaticUtil::RequireModule($report_class) or Abort( loc("Couldn't load [_1]", $report_class) );
-my @functions = $report_class->Statistics;
+my @functions
+ = map { ref($_) ? $_->[0] : $_ } $report_class->new( $session{CurrentUser} )->Statistics( Query => $Query );
</%INIT>

commit f8f63e4e3061ca089c70628fa606b8017c8a55ca
Author: sunnavy <sunnavy@bestpractical.com>
Date: Tue Feb 15 03:57:43 2022 +0800

Test searching/sorting cf values numerically

diff --git a/t/ticket/search_by_cf_numeric.t b/t/ticket/search_by_cf_numeric.t
new file mode 100644
index 0000000000..edc73767a7
--- /dev/null
+++ b/t/ticket/search_by_cf_numeric.t
@@ -0,0 +1,55 @@
+
+use strict;
+use warnings;
+
+use RT::Test nodata => 1, tests => undef;
+
+{
+ no warnings 'redefine';
+ use RT::CustomField;
+ *RT::CustomField::IsNumeric = sub { 1 }
+}
+
+my $queue = RT::Test->load_or_create_queue( Name => 'General' );
+my $cf = RT::Test->load_or_create_custom_field( Name => 'test_cf', Queue => $queue->id, Type => 'FreeformSingle' );
+my $cfid = $cf->id;
+
+my $cf2 = RT::Test->load_or_create_custom_field( Name => 'test_cf2', Queue => $queue->id, Type => 'FreeformSingle' );
+my $cf2id = $cf2->id;
+
+my @tickets = RT::Test->create_tickets(
+ { Queue => $queue->Name },
+ { Subject => 'Big', "CustomField-$cfid" => 12, "CustomField-$cf2id" => 5 },
+ { Subject => 'Small', "CustomField-$cfid" => 3, "CustomField-$cf2id" => 10 },
+);
+
+my $tickets = RT::Tickets->new( RT->SystemUser );
+$tickets->FromSQL(q{Queue = 'General' AND CF.test_cf > 5 });
+is( $tickets->Count, 1, 'Found 1 ticket' );
+is( $tickets->First->id, $tickets[0]->id, 'Found the big ticket' );
+
+$tickets->FromSQL(q{Queue = 'General' AND CF.test_cf = 12 });
+is( $tickets->Count, 1, 'Found 1 ticket' );
+is( $tickets->First->id, $tickets[0]->id, 'Found the big ticket' );
+
+$tickets->FromSQL(q{Queue = 'General' AND CF.test_cf < 5});
+is( $tickets->Count, 1, 'Found 1 ticket' );
+is( $tickets->First->id, $tickets[1]->id, 'Found the small ticket' );
+
+$tickets->FromSQL(q{Queue = 'General' AND CF.test_cf = 3});
+is( $tickets->Count, 1, 'Found 1 ticket' );
+is( $tickets->First->id, $tickets[1]->id, 'Found the small ticket' );
+
+$tickets->FromSQL(q{Queue = 'General' AND CF.test_cf < CF.test_cf2 });
+is( $tickets->Count, 1, 'Found 1 ticket' );
+is( $tickets->First->id, $tickets[1]->id, 'Found the small ticket' );
+
+$tickets->FromSQL(q{Queue = 'General'});
+is( $tickets->Count, 2, 'Found 2 tickets' );
+$tickets->OrderByCols( { FIELD => 'CustomField.test_cf' } );
+is( $tickets->First->id, $tickets[1]->id, 'Small ticket first' );
+
+$tickets->OrderByCols( { FIELD => 'CustomField.test_cf', ORDER => 'DESC' } );
+is( $tickets->First->id, $tickets[0]->id, 'Big ticket first' );
+
+done_testing;

commit dec08eb495861b03b9e7661cdedc1c8f381ff7ed
Author: sunnavy <sunnavy@bestpractical.com>
Date: Sat Feb 12 00:32:46 2022 +0800

Support to search/sort cf values numerically

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index e81a83ee90..590f33b466 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -2307,6 +2307,8 @@ sub CleanupDefaultValues {
}
}

+sub IsNumeric { 0 }
+
=head2 id

Returns the current value of id.
diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index 67ece94491..2906fe7991 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 {
@@ -543,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'};
@@ -841,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 =~ /^(!=|<>)$/) {
@@ -1164,6 +1183,23 @@ sub DistinctFieldValues {
return @values;
}

+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 e58ad24a28..3f6e8a7ca5 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1569,7 +1569,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");
@@ -3401,28 +3408,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' ) {

commit 71edc2066540bad7da5986d1081c6abc91c05c15
Author: sunnavy <sunnavy@bestpractical.com>
Date: Wed Mar 30 22:14:40 2022 +0800

Test transaction charts

diff --git a/t/charts/txn.t b/t/charts/txn.t
new file mode 100644
index 0000000000..78b19a00bc
--- /dev/null
+++ b/t/charts/txn.t
@@ -0,0 +1,185 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+use RT::Report::Transactions;
+
+my $ticket = RT::Test->create_ticket( Queue => 'General', Subject => 'test', TimeWorked => 20 );
+$ticket->Comment( Content => 'test comment', TimeTaken => 5 );
+$ticket->Comment( Content => 'test comment', TimeTaken => 15 );
+
+my $report = RT::Report::Transactions->new( RT->SystemUser );
+my %columns = $report->SetupGroupings(
+ Query => q{Type = 'Create' OR Type = 'Comment'},
+ GroupBy => ['Creator'],
+ Function => ['COUNT'],
+);
+$report->SortEntries;
+
+my @colors = RT->Config->Get("ChartColors");
+my $expected = {
+ 'thead' => [.
+ {
+ 'cells' => [.
+ {
+ 'type' => 'head',
+ 'value' => 'Creator'
+ },
+ {
+ 'color' => $colors[0],
+ 'rowspan' => 1,
+ 'type' => 'head',
+ 'value' => 'Transaction count'
+ }
+ ]
+ }
+ ],
+ 'tbody' => [.
+ {
+ 'cells' => [.
+ {
+ 'type' => 'label',
+ 'value' => 'RT_System'
+ },
+ {
+ 'query' => '(Creator = \'RT_System\')',
+ 'type' => 'value',
+ 'value' => '3'
+ }
+ ],
+ 'even' => 1
+ }
+ ],
+ 'tfoot' => [.
+ {
+ 'cells' => [.
+ {
+ 'colspan' => 1,
+ 'type' => 'label',
+ 'value' => 'Total'
+ },
+ {
+ 'type' => 'value',
+ 'value' => 3
+ }
+ ],
+ 'even' => 0
+ }
+ ],
+};
+
+my %table = $report->FormatTable(%columns);
+is_deeply( \%table, $expected, "basic table" );
+
+$report = RT::Report::Transactions->new( RT->SystemUser );
+%columns = $report->SetupGroupings(
+ Query => q{(Type = 'Create' OR Type = 'Comment') AND TimeTaken > 0},
+ GroupBy => ['Creator'],
+ Function => ['ALL(TimeTaken)'],
+);
+$report->SortEntries;
+$expected = {
+ 'thead' => [.
+ {
+ 'cells' => [.
+ {
+ 'rowspan' => 2,
+ 'type' => 'head',
+ 'value' => 'Creator'
+ },
+ {
+ 'colspan' => 4,
+ 'type' => 'head',
+ 'value' => 'Summary of Time Taken'
+ }
+ ]
+ },
+ {
+ 'cells' => [
+ {
+ 'color' => $colors[0],
+ 'type' => 'head',
+ 'value' => 'Minimum'
+ },
+ {
+ 'color' => $colors[1],
+ 'type' => 'head',
+ 'value' => 'Average'
+ },
+ {
+ 'color' => $colors[2],
+ 'type' => 'head',
+ 'value' => 'Maximum'
+ },
+ {
+ 'color' => $colors[3],
+ 'type' => 'head',
+ 'value' => 'Total'
+ }
+ ]
+ }
+ ],
+ 'tbody' => [.
+ {
+ 'cells' => [.
+ {
+ 'type' => 'label',
+ 'value' => 'RT_System'
+ },
+ {
+ 'query' => '(Creator = \'RT_System\')',
+ 'type' => 'value',
+ 'value' => '5m'
+ },
+ {
+ 'query' => '(Creator = \'RT_System\')',
+ 'type' => 'value',
+ 'value' => '13m 20s'
+ },
+ {
+ 'query' => '(Creator = \'RT_System\')',
+ 'type' => 'value',
+ 'value' => '20m'
+ },
+ {
+ 'query' => '(Creator = \'RT_System\')',
+ 'type' => 'value',
+ 'value' => '40m'
+ }
+ ],
+ 'even' => 1
+ }
+ ],
+ 'tfoot' => [.
+ {
+ 'cells' => [.
+ {
+ 'colspan' => 1,
+ 'type' => 'label',
+ 'value' => 'Total'
+ },
+ {
+ 'type' => 'value',
+ 'value' => '5m'
+ },
+ {
+ 'type' => 'value',
+ 'value' => '13m 20s'
+ },
+ {
+ 'type' => 'value',
+ 'value' => '20m'
+ },
+ {
+ 'type' => 'value',
+ 'value' => '40m'
+ }
+ ],
+ 'even' => 0
+ }
+ ],
+};
+%table = $report->FormatTable(%columns);
+is_deeply( \%table, $expected, "TimeTaken table" );
+
+done_testing;
diff --git a/t/web/charting.t b/t/web/charting.t
index 5aa64b4a15..ae2601861f 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -104,4 +104,13 @@ $m->get_ok( "/Search/Chart?Query=Requestor.Name LIKE 'root'" );
is( $m->content_type, "image/png" );
ok( length($m->content), "Has content" );

+# Test txn charts
+$m->get_ok("/Search/Chart.html?Class=RT::Transactions&Query=Type=Create");
+$m->content_like( qr{<th[^>]*>Creator\s*</th>\s*<th[^>]*>Transaction count\s*</th>}, "Grouped by creator" );
+$m->content_like( qr{RT_System\s*</th>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table" );
+$m->content_like( qr{<img src="/Search/Chart\?}, "Found image" );
+$m->get_ok("/Search/Chart?Class=RT::Transactions&Query=Type=Create");
+is( $m->content_type, "image/png" );
+ok( length( $m->content ), "Has content" );
+
done_testing;
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index 9196c57683..5ed6b4964c 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -193,11 +193,24 @@ $m->submit_form(
# We don't show saved message on page :/
$m->content_contains("Save as New", 'saved first txn search' );

+$m->get_ok( $url . "/Search/Chart.html?Class=RT::Transactions&Query=" . 'id>1' );
+
+$m->submit_form(
+ form_name => 'SaveSearch',
+ fields => {
+ SavedSearchDescription => 'first txn chart',
+ SavedSearchOwner => 'RT::System-1',
+ },
+ button => 'SavedSearchSave',
+);
+$m->content_contains("Chart first txn chart saved", 'saved first txn chart' );
+
$m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
push(
@{$args->{body}},
"saved-" . $m->dom->find('[data-description="first chart"]')->first->attr('data-name'),
"saved-" . $m->dom->find('[data-description="first txn search"]')->first->attr('data-name'),
+ "saved-" . $m->dom->find('[data-description="first txn chart"]')->first->attr('data-name'),
);

$res = $m->post(
@@ -211,5 +224,7 @@ $m->content_contains( 'Dashboard updated' );
$m->get_ok($url);
$m->text_contains('first chart');
$m->text_contains('first txn search');
+$m->text_contains('first txn chart');
+$m->text_contains('Transaction count', 'txn chart content');

done_testing;
diff --git a/t/web/saved_search_chart.t b/t/web/saved_search_chart.t
index 24c492e558..e51d4bc86d 100644
--- a/t/web/saved_search_chart.t
+++ b/t/web/saved_search_chart.t
@@ -198,4 +198,33 @@ diag "saving a chart without changing its config shows up on dashboards (I#31557
is_deeply($search->GetParameter('ChartFunction'), ['COUNT'], 'chart correctly initialized with default ChartFunction');
}

+diag 'testing transaction saved searches';
+{
+ $m->get_ok("/Search/Chart.html?Class=RT::Transactions&Query=Type=Create");
+ $m->submit_form(
+ form_name => 'SaveSearch',
+ fields => {
+ SavedSearchDescription => 'txn chart 1',
+ SavedSearchOwner => $owner,
+ },
+ button => 'SavedSearchSave',
+ );
+ $m->form_name('SaveSearch');
+ @saved_search_ids = $m->current_form->find_input('SavedSearchLoad')->possible_values;
+ shift @saved_search_ids; # first value is blank
+ my $chart_without_updates_id = $saved_search_ids[0];
+ ok( $chart_without_updates_id, 'got a saved chart id' );
+ is( scalar @saved_search_ids, 1, 'got only one saved chart id' );
+
+ my ( $privacy, $user_id, $search_id ) = $chart_without_updates_id =~ /^(RT::User-(\d+))-SavedSearch-(\d+)$/;
+ my $user = RT::User->new( RT->SystemUser );
+ $user->Load($user_id);
+ is( $user->Name, 'root', 'loaded user' );
+ my $currentuser = RT::CurrentUser->new($user);
+
+ my $search = RT::SavedSearch->new($currentuser);
+ $search->Load( $privacy, $search_id );
+ is( $search->Name, 'txn chart 1', 'loaded search' );
+}
+
done_testing;

commit 020d32ed3a43ae781ac631715aa27d2838b44d64
Author: sunnavy <sunnavy@bestpractical.com>
Date: Tue Jul 19 04:22:06 2022 +0800

Support to calculate TimeTaken in transaction search charts

diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm
index aa17a0c500..8f032ff043 100644
--- a/lib/RT/Report/Transactions.pm
+++ b/lib/RT/Report/Transactions.pm
@@ -65,7 +65,14 @@ our @GROUPINGS = (
#
# loc("Transaction count")

-our @STATISTICS = ( COUNT => [ 'Transaction count', 'Count', 'id' ], );
+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;

commit b997990619f145f3dec622da42c055f6f9c725b0
Author: sunnavy <sunnavy@bestpractical.com>
Date: Wed Mar 30 05:57:50 2022 +0800

Support transaction charts

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 9f2f61c70b..95620b12cd 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -723,6 +723,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/Entry.pm b/lib/RT/Report/Entry.pm
index 76af268b92..8d8a596b40 100644
--- a/lib/RT/Report/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -56,6 +56,9 @@ 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,
diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm
new file mode 100644
index 0000000000..aa17a0c500
--- /dev/null
+++ b/lib/RT/Report/Transactions.pm
@@ -0,0 +1,120 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+# <sales@bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::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' ], );
+
+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 0000000000..698dac223f
--- /dev/null
+++ b/lib/RT/Report/Transactions/Entry.pm
@@ -0,0 +1,60 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+# <sales@bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::Report::Transactions::Entry;
+
+use warnings;
+use strict;
+
+use base qw/RT::Report::Entry/;
+
+sub ObjectType { 'RT::Transaction' }
+
+RT::Base->_ImportOverlays();
+
+1;

commit 54ac226fadbd9b8d91ec068e7e8ee2e38c2685c6
Author: sunnavy <sunnavy@bestpractical.com>
Date: Wed Mar 30 05:45:00 2022 +0800

Refactor report code mainly to move more general part to one level up

Thus we can use it for the upcoming transaction charts.

To make subclassing easier, here we drop %GROUPINGS and %STATISTICS,
which were cached hash versions of @GROUPINGS and @STATISTICS,
respectively. As they can be generated directly from @GROUPINGS and
@STATISTICS and both variabls are not big, it doesn't make much sense to
maintain/cache them.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report.pm
similarity index 62%
copy from lib/RT/Report/Tickets.pm
copy to lib/RT/Report.pm
index 4981c0ad9d..019bc00bb8 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report.pm
@@ -2,7 +2,7 @@
#
# COPYRIGHT:
#
-# This software is Copyright (c) 1996-2022 Best Practical Solutions, LLC
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
# <sales@bestpractical.com>
#
# (Except where explicitly superseded by other copyright notices)
@@ -46,61 +46,13 @@
#
# END BPS TAGGED BLOCK }}}

-package RT::Report::Tickets;
-
-use base qw/RT::Tickets/;
-use RT::Report::Tickets::Entry;
+package RT::Report;

use strict;
use warnings;
use 5.010;
use Scalar::Util qw(weaken);
-
-__PACKAGE__->RegisterCustomFieldJoin(@$_) for
- [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
- [. "RT::Queue" => sub {
- # XXX: Could avoid join and use main.Queue with some refactoring?
- return $_[0]->{_sql_aliases}{queues} ||= $_[0]->Join(
- ALIAS1 => 'main',
- FIELD1 => 'Queue',
- TABLE2 => 'Queues',
- FIELD2 => 'id',
- );
- }
- ];
-
-our @GROUPINGS = (
- Status => 'Enum', #loc_left_pair
-
- Queue => 'Queue', #loc_left_pair
-
- InitialPriority => 'Priority', #loc_left_pair
- FinalPriority => 'Priority', #loc_left_pair
- Priority => 'Priority', #loc_left_pair
-
- Owner => 'User', #loc_left_pair
- Creator => 'User', #loc_left_pair
- LastUpdatedBy => 'User', #loc_left_pair
-
- Requestor => 'Watcher', #loc_left_pair
- Cc => 'Watcher', #loc_left_pair
- AdminCc => 'Watcher', #loc_left_pair
- Watcher => 'Watcher', #loc_left_pair
- CustomRole => 'Watcher',
-
- Created => 'Date', #loc_left_pair
- Starts => 'Date', #loc_left_pair
- Started => 'Date', #loc_left_pair
- Resolved => 'Date', #loc_left_pair
- Due => 'Date', #loc_left_pair
- Told => 'Date', #loc_left_pair
- LastUpdated => 'Date', #loc_left_pair
-
- CF => 'CustomField', #loc_left_pair
-
- SLA => 'Enum', #loc_left_pair
-);
-our %GROUPINGS;
+use RT::User;

our %GROUPINGS_META = (
Queue => {
@@ -146,7 +98,7 @@ our %GROUPINGS_META = (
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 );
+ $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
$queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
}
return () unless $queues;
@@ -274,7 +226,7 @@ our %GROUPINGS_META = (
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 );
+ $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
$queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
}
return () unless $queues;
@@ -282,14 +234,15 @@ our %GROUPINGS_META = (
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->LimitToQueue($queue->id);
+ $CustomFields->LimitToObjectId($queue->id);
}
- $CustomFields->LimitToGlobal;
while ( my $CustomField = $CustomFields->Next ) {
push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
}
@@ -356,106 +309,6 @@ our %GROUPINGS_META = (
},
);

-# 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'
-#
-# loc("Ticket count")
-# loc("Summary of time worked")
-# loc("Total time worked")
-# loc("Average time worked")
-# loc("Minimum time worked")
-# loc("Maximum time worked")
-# loc("Summary of time estimated")
-# loc("Total time estimated")
-# loc("Average time estimated")
-# loc("Minimum time estimated")
-# loc("Maximum time estimated")
-# loc("Summary of time left")
-# loc("Total time left")
-# loc("Average time left")
-# loc("Minimum time left")
-# loc("Maximum time left")
-# loc("Summary of Created to Started")
-# loc("Total Created to Started")
-# loc("Average Created to Started")
-# loc("Minimum Created to Started")
-# loc("Maximum Created to Started")
-# loc("Summary of Created to Resolved")
-# loc("Total Created to Resolved")
-# loc("Average Created to Resolved")
-# loc("Minimum Created to Resolved")
-# loc("Maximum Created to Resolved")
-# loc("Summary of Created to LastUpdated")
-# loc("Total Created to LastUpdated")
-# loc("Average Created to LastUpdated")
-# loc("Minimum Created to LastUpdated")
-# loc("Maximum Created to LastUpdated")
-# loc("Summary of Starts to Started")
-# loc("Total Starts to Started")
-# loc("Average Starts to Started")
-# loc("Minimum Starts to Started")
-# loc("Maximum Starts to Started")
-# loc("Summary of Due to Resolved")
-# loc("Total Due to Resolved")
-# loc("Average Due to Resolved")
-# loc("Minimum Due to Resolved")
-# loc("Maximum Due to Resolved")
-# loc("Summary of Started to Resolved")
-# loc("Total Started to Resolved")
-# loc("Average Started to Resolved")
-# loc("Minimum Started to Resolved")
-# loc("Maximum Started to Resolved")
-
-our @STATISTICS = (
- COUNT => ['Ticket count', 'Count', 'id'],
-);
-
-foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
- my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
- push @STATISTICS, (
- "ALL($field)" => ["Summary of $friendly", 'TimeAll', $field ],
- "SUM($field)" => ["Total $friendly", 'Time', 'SUM', $field ],
- "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
- "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
- "MAX($field)" => ["Maximum $friendly", 'Time', 'MAX', $field ],
- );
-}
-
-
-foreach my $pair (
- 'Created to Started',
- 'Created to Resolved',
- 'Created to LastUpdated',
- 'Starts to Started',
- 'Due to Resolved',
- 'Started to Resolved',
-) {
- my ($from, $to) = split / to /, $pair;
- push @STATISTICS, (
- "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ],
- "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ],
- "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
- "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
- "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],
- );
- push @GROUPINGS, $pair => 'Duration';
-
- my %extra_info = ( business_time => 1 );
- if ( keys %{RT->Config->Get('ServiceBusinessHours')} ) {
- my $business_pair = "$pair(Business Hours)";
- push @STATISTICS, (
- "ALL($business_pair)" => ["Summary of $business_pair", 'DateTimeIntervalAll', $from, $to, \%extra_info ],
- "SUM($business_pair)" => ["Total $business_pair", 'DateTimeInterval', 'SUM', $from, $to, \%extra_info ],
- "AVG($business_pair)" => ["Average $business_pair", 'DateTimeInterval', 'AVG', $from, $to, \%extra_info ],
- "MIN($business_pair)" => ["Minimum $business_pair", 'DateTimeInterval', 'MIN', $from, $to, \%extra_info ],
- "MAX($business_pair)" => ["Maximum $business_pair", 'DateTimeInterval', 'MAX', $from, $to, \%extra_info ],
- );
- push @GROUPINGS, $business_pair => 'DurationInBusinessHours';
- }
-}
-
-our %STATISTICS;
-
our %STATISTICS_META = (
Count => {
Function => sub {
@@ -558,7 +411,7 @@ sub Groupings {

my @fields;

- my @tmp = @GROUPINGS;
+ my @tmp = $self->_Groupings();
while ( my ($field, $type) = splice @tmp, 0, 2 ) {
my $meta = $GROUPINGS_META{ $type } || {};
unless ( $meta->{'SubFields'} ) {
@@ -587,7 +440,6 @@ sub IsValidGrouping {

my ($key, $subkey) = split /(?<!CustomRole)\./, $args{'GroupBy'}, 2;

- %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
my $type = $self->_GroupingType( $key );
return 0 unless $type;
return 1 unless $subkey;
@@ -607,7 +459,7 @@ sub IsValidGrouping {

sub Statistics {
my $self = shift;
- return map { ref($_)? $_->[0] : $_ } @STATISTICS;
+ return map { ref($_)? $_->[0] : $_ } $self->_Statistics;
}

sub Label {
@@ -658,45 +510,6 @@ sub SetupGroupings {
@_
);

- $self->FromSQL( $args{'Query'} ) if $args{'Query'};
-
- # Apply ACL checks
- $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
-
- # See if our query is distinct
- if (not $self->{'joins_are_distinct'} and $self->_isJoined) {
- # If it isn't, we need to do this in two stages -- first, find
- # the distinct matching tickets (with no group by), then search
- # within the matching tickets grouped by what is wanted.
- $self->Columns( 'id' );
- if ( RT->Config->Get('UseSQLForACLChecks') ) {
- my $query = $self->BuildSelectQuery( PreferBind => 0 );
- $self->CleanSlate;
- $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 );
- }
- else {
- # ACL is done in Next call
- my @match = (0);
- while ( my $row = $self->Next ) {
- push @match, $row->id;
- }
-
- # Replace the query with one that matches precisely those
- # tickets, with no joins. We then mark it as having been ACL'd,
- # since it was by dint of being in the search results above
- $self->CleanSlate;
- while ( @match > 1000 ) {
- my @batch = splice( @match, 0, 1000 );
- $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
- }
- $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
- }
- $self->{'_sql_current_user_can_see_applied'} = 1
- }
-
-
- %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
-
my $i = 0;

my @group_by = grep defined && length,
@@ -742,7 +555,7 @@ sub SetupGroupings {
push @{ $res{'Groups'} }, $group_by->{'NAME'};
}

- %STATISTICS = @STATISTICS unless keys %STATISTICS;
+ my %statistics = $self->_Statistics;

my @function = grep defined && length,
ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
@@ -751,8 +564,8 @@ sub SetupGroupings {
$e = {
TYPE => 'statistic',
KEY => $e,
- INFO => $STATISTICS{ $e },
- META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
+ INFO => $statistics{ $e },
+ META => $STATISTICS_META{ $statistics{ $e }[1] },
POSITION => $i++,
};
unless ( $e->{'INFO'} && $e->{'META'} ) {
@@ -790,334 +603,9 @@ sub SetupGroupings {

$self->{'column_info'} = \%column_info;

- if ($args{Query}
- && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $column_info{$_} } @{ $res{Groups} } )
- || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ }
- values %column_info )
- || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} }
- values %column_info ) )
- )
- {
- # Need to do the groupby/calculation at Perl level
- $self->{_query} = $args{'Query'};
- }
- else {
- delete $self->{_query};
- }
-
return %res;
}

-=head2 _DoSearch
-
-Subclass _DoSearch from our parent so we can go through and add in empty
-columns if it makes sense
-
-=cut
-
-sub _DoSearch {
- my $self = shift;
-
- # When groupby/calculation can't be done at SQL level, do it at Perl level
- if ( $self->{_query} ) {
- my $tickets = RT::Tickets->new( $self->CurrentUser );
- $tickets->FromSQL( $self->{_query} );
- my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList;
- my %info;
- while ( my $ticket = $tickets->Next ) {
- my @keys;
- my $max = 1;
- for my $group ( @groups ) {
- my $value;
-
- if ( $ticket->_Accessible($group->{KEY}, 'read' )) {
- if ( $group->{SUBKEY} ) {
- my $method = "$group->{KEY}Obj";
- if ( my $obj = $ticket->$method ) {
- if ( $group->{INFO} eq 'Date' ) {
- if ( $obj->Unix > 0 ) {
- $value = $obj->Strftime( $GROUPINGS_META{Date}{StrftimeFormat}{ $group->{SUBKEY} },
- Timezone => 'user' );
- }
- else {
- $value = $self->loc('(no value)')
- }
- }
- else {
- $value = $obj->_Value($group->{SUBKEY});
- }
- $value //= $self->loc('(no value)');
- }
- }
- $value //= $ticket->_Value( $group->{KEY} ) // $self->loc('(no value)');
- }
- elsif ( $group->{INFO} eq 'Watcher' ) {
- my @values;
- if ( $ticket->can($group->{KEY}) ) {
- my $method = $group->{KEY};
- push @values, @{$ticket->$method->UserMembersObj->ItemsArrayRef};
- }
- elsif ( $group->{KEY} eq 'Watcher' ) {
- push @values, @{$ticket->$_->UserMembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/;
- }
- else {
- RT->Logger->error("Unsupported group by $group->{KEY}");
- next;
- }
-
- @values = map { $_->_Value( $group->{SUBKEY} || 'Name' ) } @values;
- @values = $self->loc('(no value)') unless @values;
- $value = \@values;
- }
- elsif ( $group->{INFO} eq 'CustomField' ) {
- my ($id) = $group->{SUBKEY} =~ /{(\d+)}/;
- my $values = $ticket->CustomFieldValues($id);
- if ( $values->Count ) {
- $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ];
- }
- else {
- $value = $self->loc('(no value)');
- }
- }
- elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) {
- my $business_time = $1;
-
- if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) {
- my $start = $1;
- my $end = $2;
- my $start_method = $start . 'Obj';
- my $end_method = $end . 'Obj';
- if ( $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0 ) {
- my $seconds;
-
- if ($business_time) {
- $seconds = $ticket->CustomDateRange(
- '',
- { value => "$end - $start",
- business_time => 1,
- format => sub { $_[0] },
- }
- );
- }
- else {
- $seconds = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
- }
-
- if ( $group->{SUBKEY} eq 'Default' ) {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show},
- Short => $group->{META}{Short},
- MaxUnit => $business_time ? 'hour' : 'year',
- );
- }
- else {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show} // 3,
- Short => $group->{META}{Short} // 1,
- MaxUnit => lc $group->{SUBKEY},
- MinUnit => lc $group->{SUBKEY},
- Unit => lc $group->{SUBKEY},
- );
- }
- }
- }
- else {
- my %ranges = RT::Ticket->CustomDateRanges;
- if ( my $spec = $ranges{$group->{FIELD}} ) {
- if ( $group->{SUBKEY} eq 'Default' ) {
- $value = $ticket->CustomDateRange( $group->{FIELD}, $spec );
- }
- else {
- my $seconds = $ticket->CustomDateRange( $group->{FIELD},
- { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } );
-
- if ( defined $seconds ) {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show} // 3,
- Short => $group->{META}{Short} // 1,
- MaxUnit => lc $group->{SUBKEY},
- MinUnit => lc $group->{SUBKEY},
- Unit => lc $group->{SUBKEY},
- );
- }
- }
- }
- }
-
- $value //= $self->loc('(no value)');
- }
- else {
- RT->Logger->error("Unsupported group by $group->{KEY}");
- next;
- }
- push @keys, $value;
- }
-
- # @keys could contain arrayrefs, so we need to expand it.
- # e.g. "open", [ "root", "foo" ], "General" )
- # will be expanded to:
- # "open", "root", "General"
- # "open", "foo", "General"
-
- my @all_keys;
- for my $key (@keys) {
- if ( ref $key eq 'ARRAY' ) {
- if (@all_keys) {
- my @new_all_keys;
- for my $keys ( @all_keys ) {
- push @new_all_keys, [ @$keys, $_ ] for @$key;
- }
- @all_keys = @new_all_keys;
- }
- else {
- push @all_keys, [$_] for @$key;
- }
- }
- else {
- if (@all_keys) {
- @all_keys = map { [ @$_, $key ] } @all_keys;
- }
- else {
- push @all_keys, [$key];
- }
- }
- }
-
- my @fields = grep { $_->{TYPE} eq 'statistic' }
- map { $self->ColumnInfo($_) } $self->ColumnsList;
-
- while ( my $field = shift @fields ) {
- for my $keys (@all_keys) {
- my $key = join ';;;', @$keys;
- if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) {
- $info{$key}{ $field->{NAME} }++;
- }
- elsif ( $field->{NAME} =~ /^postfunction/ ) {
- if ( $field->{MAP} ) {
- my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/;
- for my $item ( values %{ $field->{MAP} } ) {
- push @fields,
- {
- NAME => $item->{NAME},
- FIELD => $item->{FIELD},
- INFO => [.
- '', $meta_type,
- $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '',
- @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ],
- ],
- };
- }
- }
- }
- elsif ( $field->{INFO}[1] eq 'Time' ) {
- if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) {
- my $method = $1;
- my $type = $field->{INFO}[2];
- my $name = lc $field->{NAME};
-
- $info{$key}{$name}
- = $self->_CalculateTime( $type, $ticket->$method * 60, $info{$key}{$name} ) || 0;
- }
- else {
- RT->Logger->error("Unsupported field $field->{NAME}");
- }
- }
- elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) {
- my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} };
- my $name = lc $field->{NAME};
- $info{$key}{$name} ||= 0;
-
- my $start_method = $start . 'Obj';
- my $end_method = $end . 'Obj';
- next unless $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0;
-
- my $value;
- if ($extra_info->{business_time}) {
- $value = $ticket->CustomDateRange(
- '',
- { value => "$end - $start",
- business_time => 1,
- format => sub { return $_[0] },
- }
- );
- }
- else {
- $value = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
- }
-
- $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
- }
- elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) {
- my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} };
- my $name = lc $field->{NAME};
- $info{$key}{$name} ||= 0;
-
- my $value;
- my %ranges = RT::Ticket->CustomDateRanges;
- if ( my $spec = $ranges{$range_name} ) {
- $value = $ticket->CustomDateRange(
- $range_name,
- {
- ref $spec eq 'HASH' ? %$spec : ( value => $spec ),
- format => sub { $_[0] },
- }
- );
- }
- $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
- }
- else {
- RT->Logger->error("Unsupported field $field->{INFO}[1]");
- }
- }
- }
-
- for my $keys (@all_keys) {
- my $key = join ';;;', @$keys;
- push @{ $info{$key}{ids} }, $ticket->id;
- }
- }
-
- # Make generated results real SB results
- for my $key ( keys %info ) {
- my @keys = split /;;;/, $key;
- my $row;
- for my $group ( @groups ) {
- $row->{lc $group->{NAME}} = shift @keys;
- }
- for my $field ( keys %{ $info{$key} } ) {
- my $value = $info{$key}{$field};
- if ( ref $value eq 'HASH' && $value->{calculate} ) {
- $row->{$field} = $value->{calculate}->($value);
- }
- else {
- $row->{$field} = $info{$key}{$field};
- }
- }
- my $item = $self->NewItem();
- $item->LoadFromHash($row);
- $self->AddRecord($item);
- }
- $self->{must_redo_search} = 0;
- $self->{is_limited} = 1;
- $self->PostProcessRecords;
-
- return;
- }
-
- $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
@@ -1142,28 +630,6 @@ sub _FieldToFunction {
return $code->( $self, %args );
}

-
-# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we
-# don't want.
-sub Next {
- my $self = shift;
- $self->RT::SearchBuilder::Next(@_);
-
-}
-
-sub 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;

@@ -1670,36 +1136,31 @@ sub _CalculateTime {
return $current;
}

-sub new {
- my $self = shift;
- $self->_SetupCustomDateRanges;
- return $self->SUPER::new(@_);
-}
-
-
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 ) {
+ for my $field ( grep {ref} $self->_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;
+ 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;
+ 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;
@@ -1707,7 +1168,7 @@ sub _SetupCustomDateRanges {
}

# Add new ones
- my %ranges = RT::Ticket->CustomDateRanges;
+ my %ranges = $self->_SingularClass->CustomDateRanges;
for my $name ( sort keys %ranges ) {
my %extra_info;
my $spec = $ranges{$name};
@@ -1726,9 +1187,8 @@ sub _SetupCustomDateRanges {
);
}

- @GROUPINGS = @new_groupings;
- @STATISTICS = @new_statistics;
- %GROUPINGS = %STATISTICS = ();
+ $self->_Groupings( @new_groupings );
+ $self->_Statistics( @new_statistics );

return 1;
}
@@ -1738,13 +1198,77 @@ sub _GroupingType {
my $key = shift or return;
# keys for custom roles are like "CustomRole.{1}"
$key = 'CustomRole' if $key =~ /^CustomRole/;
- return $GROUPINGS{$key};
+ 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 {
- return 'Status';
+ 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" }
+
+
RT::Base->_ImportOverlays();

1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Entry.pm
similarity index 95%
copy from lib/RT/Report/Tickets/Entry.pm
copy to lib/RT/Report/Entry.pm
index 167f7f2b87..76af268b92 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -2,7 +2,7 @@
#
# COPYRIGHT:
#
-# This software is Copyright (c) 1996-2022 Best Practical Solutions, LLC
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
# <sales@bestpractical.com>
#
# (Except where explicitly superseded by other copyright notices)
@@ -46,7 +46,7 @@
#
# END BPS TAGGED BLOCK }}}

-package RT::Report::Tickets::Entry;
+package RT::Report::Entry;

use warnings;
use strict;
@@ -96,12 +96,12 @@ sub RawValue {
return (shift)->__Value( @_ );
}

-sub ObjectType {
- return 'RT::Ticket';
-}
+# Used in RT::SearchBuilder::JoinTransactions and CustomFieldLookupType
+sub ObjectType { die "should be subclassed" }

sub CustomFieldLookupType {
- RT::Ticket->CustomFieldLookupType
+ my $self = shift;
+ return $self->ObjectType->CustomFieldLookupType;
}

sub Query {
diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 4981c0ad9d..11040e0091 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,261 +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 = (@_);
- if ( $args{FIELD} eq 'id' ) {
- 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,
- },
-);

# 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'
@@ -454,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 /(?<!CustomRole)\./, $args{'GroupBy'}, 2;
-
- %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
- my $type = $self->_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 = (
@@ -694,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 = $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 /(?<!CustomRole)\./, $e, 2;
- $e = { $self->_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
@@ -808,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;

@@ -836,7 +284,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 {
@@ -1108,643 +556,25 @@ 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;
- }
+ $self->_PostSearch();
}

-=head2 _FieldToFunction FIELD
-
-Returns a tuple of the field or a database function to allow grouping on that
-field.
-
-=cut
-
-sub _FieldToFunction {
+sub new {
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->_SetupCustomDateRanges;
+ return $self->SUPER::new(@_);
}

-
-# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we
+# Gotta skip over RT::Ticket->Next, since it does all sorts of crazy magic we
# don't want.
sub Next {
my $self = shift;
$self->RT::SearchBuilder::Next(@_);
-
}

-sub NewItem {
- my $self = shift;
- my $res = 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';
-
- unless ( ref $v ) {
- return $self->loc("(no value)") unless defined $v && length $v;
- return RT::Date->new( $self->CurrentUser )->DurationAsString(
- $v, Show => 3, Short => 1, MaxUnit => $max_unit,
- );
- }
-
- 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;
- }
- 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 );
- 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 ) || {};
- $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 )
- 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} = 1;
- }
-
- 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};
-}
-
-sub DefaultGroupBy {
- return 'Status';
-}
-
RT::Base->_ImportOverlays();

1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 167f7f2b87..00a220c6a5 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 $raw = $self->RawValue( $name, @_ );
-
- if ( my $code = $self->Report->LabelValueCode( $name ) ) {
- $raw = $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw );
- 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/share/po/zh_CN.po b/share/po/zh_CN.po
index 91156e86ab..fefe678ed4 100644
--- a/share/po/zh_CN.po
+++ b/share/po/zh_CN.po
@@ -13074,3 +13074,5 @@ msgstr "?"
msgid "your browser did not supply a Referrer header"
msgstr ""

+msgid "Custom field"
+msgstr "????"

commit 5551a4e96bd402d140b4aba8120bad3fd086c913
Author: sunnavy <sunnavy@bestpractical.com>
Date: Tue Mar 29 22:27:11 2022 +0800

Refactor chart code to avoid hard coded class and group by

This is the preparation work to support transaction charts.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 407e034d03..4981c0ad9d 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -701,7 +701,7 @@ sub SetupGroupings {

my @group_by = grep defined && length,
ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
- @group_by = ('Status') unless @group_by;
+ @group_by = $self->DefaultGroupBy unless @group_by;

my $distinct_results = 1;
foreach my $e ( splice @group_by ) {
@@ -1741,6 +1741,10 @@ sub _GroupingType {
return $GROUPINGS{$key};
}

+sub DefaultGroupBy {
+ return 'Status';
+}
+
RT::Base->_ImportOverlays();

1;
diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index 4a740d73b5..67ece94491 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -267,6 +267,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
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index c6f4f4ea76..9cc65639b3 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 acc121d5c8..d511a847a3 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'
</%args>
<%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 c856b6278a..2402a7c45d 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 @@ $m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );

my $title = loc( "Grouped search results");

-my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height ExtraQueryParams), @ExtraQueryParams );
+my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height Class ExtraQueryParams), @ExtraQueryParams );
my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
SearchType => 'Chart',
SearchFields => [@search_fields],
@@ -147,6 +151,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
<form method="get" action="<% RT->Config->Get('WebPath') %>/Search/Chart.html">
<input type="hidden" class="hidden" name="Query" value="<% $query{Query} %>" />
<input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
+<input type="hidden" class="hidden" name="Class" value="<% $Class %>" />

% if ( $query{ExtraQueryParams} ) {
% for my $input ( ref $query{ExtraQueryParams} eq 'ARRAY' ? @{$query{ExtraQueryParams}} : $query{ExtraQueryParams} ) {
@@ -165,6 +170,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
Default => $query{'GroupBy'}[0],
Stacked => $query{'GroupBy'}[0] eq ($query{StackedGroupBy} // '') ? 1 : 0,
StackedId => 'StackedGroupBy-1',
+ Class => $Class,
&>
</fieldset>
<fieldset><legend><% loc('and then') %></legend>
@@ -175,6 +181,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
ShowEmpty => 1,
Stacked => $query{'GroupBy'}[1] && ($query{'GroupBy'}[1] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
StackedId => 'StackedGroupBy-2',
+ Class => $Class,
&>
</fieldset>
<fieldset><legend><% loc('and then') %></legend>
@@ -185,19 +192,20 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
ShowEmpty => 1,
Stacked => $query{'GroupBy'}[2] && ($query{'GroupBy'}[2] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
StackedId => 'StackedGroupBy-3',
+ Class => $Class,
&>
</fieldset>
</&>

<&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
<fieldset><legend><% loc('Calculate values of') %></legend>
- <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0] &>
+ <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0], Class => $Class, &>
</fieldset>
<fieldset><legend><% loc('and then') %></legend>
- <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1 &>
+ <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1, Class => $Class, &>
</fieldset>
<fieldset><legend><% loc('and then') %></legend>
- <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1 &>
+ <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1, Class => $Class, &>
</fieldset>
</&>

@@ -330,7 +338,7 @@ jQuery( function() {

<div class="col-xl-6">
<div class="saved-search">
- <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
+ <& /Widgets/SavedSearch:show, Class => $Class, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
</div>
</div>
</div>
@@ -340,4 +348,5 @@ jQuery( function() {

<%ARGS>
@ExtraQueryParams => ()
+$Class => 'RT::Tickets'
</%ARGS>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 021a07cfb7..474c341537 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'
</%args>
<%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 01ab9ac4fe..c9af72ac26 100644
--- a/share/html/Search/Elements/ChartTable
+++ b/share/html/Search/Elements/ChartTable
@@ -48,6 +48,7 @@
<%ARGS>
%Table => ()
$Query => undef
+$Class => 'RT::Tickets'
</%ARGS>
<%INIT>

@@ -93,7 +94,7 @@ foreach my $section (qw(thead tbody tfoot)) {
if ( my $q = $cell->{'query'} ) {
$m->out(
'<a href="'. $eh->(RT->Config->Get('WebPath')) .'/Search/Results.html'
- .'?Query='. $eu->(join ' AND ', map "($_)", grep defined && length, $Query, $q)
+ ."?Class=$Class&Query=". $eu->(join ' AND ', map "($_)", grep defined && length, $Query, $q)
. $eh->('&') . $base_query
. '">'
);
diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index 6871e702bb..0788709a8a 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -104,7 +104,7 @@
<div class="form-row">
<div class="label col-4"><&|/l&>Load saved search</&>:</div>
<div class="col-8 input-group">
-<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type &>
+<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type, Class => $Class &>
<input type="submit" class="button btn btn-primary" value="<% loc('Load') %>" id="SavedSearchLoadSubmit" name="SavedSearchLoadSubmit" />
</div>
</div>
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index aa6422b04a..03d9bab39f 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -72,8 +72,10 @@ while ( my ($value, $display) = splice @functions, 0, 2 ) {
$Name => 'ChartFunction'
$Default => 'COUNT'
$ShowEmpty => 0
+$Class => $Class
</%ARGS>
<%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 = $report_class->Statistics;
</%INIT>
diff --git a/share/html/Search/Elements/SelectGroupBy b/share/html/Search/Elements/SelectGroupBy
index 74028d9f74..881d331f8a 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'
</%args>
<select name="<% $Name %>" class="cascade-by-optgroup">
% if ( $ShowEmpty ) {
@@ -85,7 +86,8 @@ while ( my ($label, $value) = splice @options, 0, 2 ) {
</span>

<%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 @options = $report->Groupings( Query => $Query );
</%init>
diff --git a/share/html/Search/Elements/SelectSearchesForObjects b/share/html/Search/Elements/SelectSearchesForObjects
index be3704a3b1..4bb4308a72 100644
--- a/share/html/Search/Elements/SelectSearchesForObjects
+++ b/share/html/Search/Elements/SelectSearchesForObjects
@@ -65,6 +65,7 @@ $SearchType => $Class eq 'RT::Transactions' ? 'Transaction' : $Class eq 'RT::Ass
% next if ($search->SubValue('SearchType')
% && $search->SubValue('SearchType') ne $SearchType);
% next if ($search->SubValue('SearchType') // '') eq 'RT::Transactions' && ($search->SubValue('ObjectType') // '') ne $ObjectType;
+% next if $SearchType eq 'Chart' && ( $search->SubValue('Class') || 'RT::Tickets' ) ne ( $Class || 'RT::Tickets' );
<option value="<%ref($object)%>-<%$object->id%>-SavedSearch-<%$search->Id%>"><%$search->Description||loc('Unnamed search')%></option>
% }
</optgroup>
diff --git a/share/html/Search/JSChart b/share/html/Search/JSChart
index bc3f9d3ac2..0d4013fb39 100644
--- a/share/html/Search/JSChart
+++ b/share/html/Search/JSChart
@@ -55,6 +55,7 @@ $Width => undef
$Height => undef
$SavedSearchId => ''
$StackedGroupBy => undef
+$Class => 'RT::Tickets'
</%args>

% my $id = join '-', 'search-chart', $SavedSearchId || ();
@@ -191,12 +192,11 @@ $Width ||= ($ChartStyle =~ /\bpie\b/ ? 400 : 600);
$Height ||= ($ChartStyle =~ /\bpie\b/ ? $Width : 400);
$Height = $Width if $ChartStyle =~ /\bpie\b/;

-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'} );

-# Default GroupBy we use in RT::Report::Tickets, we also need it here to
-# generate sub queries.
-@GroupBy = 'Status' unless @GroupBy;
+@GroupBy = $report_class->DefaultGroupBy unless @GroupBy;

my %columns;
if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
diff --git a/share/html/Widgets/SavedSearch b/share/html/Widgets/SavedSearch
index 0359ae752d..cab16e1505 100644
--- a/share/html/Widgets/SavedSearch
+++ b/share/html/Widgets/SavedSearch
@@ -178,6 +178,7 @@ $defaults => {}
$self->{CurrentSearch}{Object} ?
( Object => $self->{CurrentSearch}{Object},
Description => $self->{CurrentSearch}{Object}->Description, ) : (),
+ Class => $Class,
&><br />
<%PERL>
foreach my $field ( @{$self->{SearchFields}} ) {
@@ -197,6 +198,7 @@ $self => undef
$Action => ''
$Title => loc('Saved searches')
$AllowCopy => 0
+$Class => 'RT::Tickets'
</%ARGS>
<%init>
</%init>

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


hooks/post-receive
--
rt
_______________________________________________
rt-commit mailing list
rt-commit@lists.bestpractical.com
https://lists.bestpractical.com/mailman/listinfo/rt-commit