Mailing List Archive

rt branch 5.0/txn-search-chart created. rt-5.0.5-79-gfc23d61855
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 fc23d61855943cfd7199a65b970194d6362ca6b5 (commit)

- Log -----------------------------------------------------------------
commit fc23d61855943cfd7199a65b970194d6362ca6b5
Author: sunnavy <sunnavy@bestpractical.com>
Date: Fri Dec 1 12:54:22 2023 -0500

Limit lookup type for custom roles in reports

Previously custom roles were only for tickets, but not any more since we
added assets support.

diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index eac03645fe..916ae47a0c 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -134,6 +134,10 @@ our %GROUPINGS_META = (
return () unless $queues;

my $crs = RT::CustomRoles->new( $self->CurrentUser );
+ $crs->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
+ # Adding this to avoid returning all records when no queues are available.
+ $crs->LimitToObjectId(0);
+
for my $id ( keys %$queues ) {
my $queue = RT::Queue->new( $self->CurrentUser );
$queue->Load($id);

commit 7fba78828163a38b7848e99468d47ed06bcf00af
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..3697d75df3
--- /dev/null
+++ b/t/charts/calculate-numeric-cf.t
@@ -0,0 +1,177 @@
+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::CustomField::NumericPrecision = sub {
+ my $self = shift;
+ return $self->Name eq 'Cost' ? 0 : undef;
+ };
+}
+
+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 3b8bd556682507b00272dd7ddfe10d4ff1625d2a
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/CustomField.pm b/lib/RT/CustomField.pm
index 046a6bd108..b7a191544a 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -2391,6 +2391,16 @@ Right now you need to override this method to mark the chosen ones numeric.

sub IsNumeric { 0 }

+=head2 NumericPrecision
+
+Returns the precision if the custom field is numeric, default is C<undef>.
+
+Right now you need to override this method to customize it.
+
+=cut
+
+sub NumericPrecision { undef }
+
=head2 id

Returns the current value of id.
diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index 8a2c381128..eac03645fe 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -452,6 +452,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 = RT->DatabaseHandle->CastAsDecimal('Content');
+ my $precision = $cf->NumericPrecision() // 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 = RT->DatabaseHandle->CastAsDecimal('Content');
+ my $precision = $cf->NumericPrecision() // 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 {
@@ -507,8 +542,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 {
@@ -604,8 +640,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;
@@ -1294,6 +1329,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;
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index ee1aeab4b4..3da8084c61 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,10 +74,12 @@ $Name => 'ChartFunction'
$Default => 'COUNT'
$ShowEmpty => 0
$Class => $Class
+$Query => ''
</%ARGS>
<%INIT>
my $report_class = ( $Class || 'RT::Tickets' )->ReportClass
or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
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 9d70f8c98e6147848489afd9c2bee9b874a5a089
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..6bf1129e43
--- /dev/null
+++ b/t/ticket/search_by_cf_numeric.t
@@ -0,0 +1,63 @@
+
+use strict;
+use warnings;
+
+use RT::Test nodata => 1, tests => undef;
+
+{
+ no warnings 'redefine';
+ use RT::CustomField;
+ *RT::CustomField::IsNumeric = sub {
+ my $self = shift;
+ if ( $self->__Value('Name') =~ /^test_cf/ ) {
+ return 1;
+ }
+ else {
+ return 0;
+ }
+ }
+}
+
+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 65198de31631dcc07d494127c46545c5db519c48
Author: sunnavy <sunnavy@bestpractical.com>
Date: Sat Feb 12 00:32:46 2022 +0800

Support to search/sort cf values numerically

diff --git a/etc/cpanfile b/etc/cpanfile
index c247abe294..07ee82d182 100644
--- a/etc/cpanfile
+++ b/etc/cpanfile
@@ -22,7 +22,7 @@ requires 'DateTime', '>= 0.44';
requires 'DateTime::Format::Natural', '>= 0.67';
requires 'DateTime::Locale', '>= 0.40, != 1.00, != 1.01';
requires 'DBI', '>= 1.37';
-requires 'DBIx::SearchBuilder', '>= 1.77';
+requires 'DBIx::SearchBuilder', '>= 1.80';
requires 'Devel::GlobalDestruction';
requires 'Devel::StackTrace', '>= 1.19, != 1.28, != 1.29';
requires 'Digest::base';
diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 8326ab92e6..046a6bd108 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -2381,6 +2381,16 @@ sub CleanupDefaultValues {
}
}

+=head2 IsNumeric
+
+Returns true if the custom field is supposed to be numeric, default is 0.
+
+Right now you need to override this method to mark the chosen ones numeric.
+
+=cut
+
+sub IsNumeric { 0 }
+
=head2 id

Returns the current value of id.
diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index 798e223c39..6a707ace01 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 => RT->DatabaseHandle->CastAsDecimal('Content') )
+ : ()
+ };
}

sub OrderByCols {
@@ -545,9 +552,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} = RT->DatabaseHandle->CastAsDecimal( "$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'};
@@ -843,17 +861,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 =~ /^(!=|<>)$/) {
diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index b9d0fbae04..275fe6653a 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1713,7 +1713,14 @@ sub OrderByCols {
ENTRYAGGREGATOR => 'AND'
);
push @res, { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' },
- { %$row, ALIAS => $ocfvs, FIELD => 'Content' };
+ {
+ %$row,
+ ALIAS => $ocfvs,
+ FIELD => 'Content',
+ blessed $cf && $cf->IsNumeric
+ ? ( FUNCTION => RT->DatabaseHandle->CastAsDecimal('Content') )
+ : ()
+ };
}
else {
RT->Logger->warning("Couldn't load user custom field $cf_name");
@@ -3597,28 +3604,39 @@ sub _parser {
$value = "main.$value" if $class eq 'RT::Tickets' && $value =~ /^\w+$/;

if ( $class eq 'RT::ObjectCustomFieldValues' ) {
+ my $cast_as;
+ if ( $meta->[0] eq 'CUSTOMFIELD' ) {
+ my ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $subkey );
+ if ( $cf && $cf->IsNumeric ) {
+ $cast_as = 'DECIMAL';
+ }
+ }
+
if ( RT->Config->Get('DatabaseType') eq 'Pg' ) {
- my $cast_to;
- if ($subkey) {
+ if ( !$cast_as ) {
+ 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_as = 'INTEGER';
+ }
}
- elsif ( $meta->{type} eq 'datetime' ) {
- $cast_to = 'TIMESTAMP';
+ elsif ( my $meta = $self->RecordClass->_ClassAccessible->{$key} ) {
+ if ( $meta->{is_numeric} ) {
+ $cast_as = 'INTEGER';
+ }
+ elsif ( $meta->{type} eq 'datetime' ) {
+ $cast_as = 'TIMESTAMP';
+ }
}
}
-
- $value = "CAST($value AS $cast_to)" if $cast_to;
+ $value = "CAST($value AS $cast_as)" if $cast_as;
}
elsif ( RT->Config->Get('DatabaseType') eq 'Oracle' ) {
- if ($subkey) {
+ if ( $cast_as && $cast_as eq 'DECIMAL' ) {
+ $value = "TO_NUMBER($value)";
+ }
+ elsif ($subkey) {

# like Requestor.id
if ( $subkey eq 'id' ) {

commit a21a3c605215eb1487d015bd08ca6eeae32aadb3
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 b5286382b3..1cf8170942 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -132,4 +132,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 8b3317b95a..26366cc101 100644
--- a/t/web/saved_search_chart.t
+++ b/t/web/saved_search_chart.t
@@ -232,4 +232,33 @@ diag "test chart content with default parameters";
ok( !exists $search->{Attribute}->Content->{''}, 'No empty key' );
}

+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 cea4882b0ca1fddb217424fcfe908b4caea8e51a
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 8a80669c88..3d285befc2 100644
--- a/lib/RT/Report/Transactions.pm
+++ b/lib/RT/Report/Transactions.pm
@@ -76,7 +76,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 bbbb0fb0cad62aed2583e82022153f80f893b978
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 57c7240d57..31836b3830 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -760,6 +760,9 @@ sub BuildMainNav {
elsif ( $class eq 'RT::Assets' ) {
$current_search_menu->child( bulk => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" );
}
+ elsif ( $class eq 'RT::Transactions' ) {
+ $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" );
+ }

my $more = $current_search_menu->child( more => title => loc('Feeds') );

diff --git a/lib/RT/Report/Entry.pm b/lib/RT/Report/Entry.pm
index 6782bf997b..c2b9fac757 100644
--- a/lib/RT/Report/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -72,6 +72,9 @@ the entry class for L<RT::Report::Tickets> is L<RT::Report::Tickets::Entry>.
# 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..8a80669c88
--- /dev/null
+++ b/lib/RT/Report/Transactions.pm
@@ -0,0 +1,128 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2023 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;
+
+
+=head1 NAME
+
+RT::Report::Transactions - Transaction search charts
+
+=head1 DESCRIPTION
+
+This is the backend class for transaction search charts.
+
+=cut
+
+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();
+}
+
+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..8cacf42965
--- /dev/null
+++ b/lib/RT/Report/Transactions/Entry.pm
@@ -0,0 +1,58 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2023 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/;
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index a106fc508e..0733856126 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -164,7 +164,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
% }

<&| /Widgets/TitleBox, title => loc('Group by'), class => "chart-group-by" &>
- <fieldset><legend><% loc('Group tickets by') %></legend>
+ <fieldset><legend><% loc('Group [_1] by', loc(lc $Class->Table)) %></legend>
<& Elements/SelectGroupBy,
Name => 'GroupBy',
Query => $query{Query},

commit 3ba13e5819fcba098318f22f51e4233a232c3dd7
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 9532acedb9..8a2c381128 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report.pm
@@ -46,61 +46,43 @@
#
# 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);
+use RT::User;

-__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
+=head1 NAME

- Queue => 'Queue', #loc_left_pair
+RT::Report - Base class of RT search charts

- InitialPriority => 'Priority', #loc_left_pair
- FinalPriority => 'Priority', #loc_left_pair
- Priority => 'Priority', #loc_left_pair
+=head1 DESCRIPTION

- Owner => 'User', #loc_left_pair
- Creator => 'User', #loc_left_pair
- LastUpdatedBy => 'User', #loc_left_pair
+This class defines fundamental bits of code that all report classes like
+L<RT::Report::Tickets> can make use of.

- Requestor => 'Watcher', #loc_left_pair
- Cc => 'Watcher', #loc_left_pair
- AdminCc => 'Watcher', #loc_left_pair
- Watcher => 'Watcher', #loc_left_pair
- CustomRole => 'Watcher',
+Subclasses are supposed to have the following things defined:

- 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
+=over

- CF => 'CustomField', #loc_left_pair
+=item @GROUPINGS

- SLA => 'Enum', #loc_left_pair
-);
-our %GROUPINGS;
+Group By options are defined here.
+
+=item @STATISTICS
+
+Calculation options are defined here.
+
+=back
+
+Check L<RT::Report::Tickets> for real examples.
+
+=head1 METHODS
+
+=cut

our %GROUPINGS_META = (
Queue => {
@@ -146,7 +128,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;
@@ -275,7 +257,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;
@@ -283,14 +265,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 ."}";
}
@@ -375,106 +358,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 {
@@ -577,7 +460,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'} ) {
@@ -606,7 +489,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;
@@ -626,7 +508,7 @@ sub IsValidGrouping {

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

sub Label {
@@ -677,45 +559,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,
@@ -761,7 +604,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'});
@@ -770,8 +613,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'} ) {
@@ -809,363 +652,12 @@ 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;
-
- my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) }
- keys %{ RT->Config->Get('ServiceBusinessHours') || {} };
-
- while ( my $ticket = $tickets->Next ) {
- my $bh = $ticket->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $ticket->SLA }{BusinessHours} : '';
-
- my @keys;
- my $max = 1;
- my @extra_keys;
- my %css_class;
- for my $group ( @groups ) {
- my $value;
-
- if ( $ticket->_Accessible($group->{KEY}, 'read' )) {
- if ( $group->{SUBKEY} ) {
- my $method = "$group->{KEY}Obj";
- if ( my $obj = $ticket->$method ) {
- if ( $group->{INFO} eq 'Date' ) {
- if ( $obj->Unix > 0 ) {
- $value = $obj->Strftime( $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, map { $_->MemberId } @{$ticket->$method->MembersObj->ItemsArrayRef};
- }
- elsif ( $group->{KEY} eq 'Watcher' ) {
- push @values, map { $_->MemberId } @{$ticket->$_->MembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/;
- }
- else {
- RT->Logger->error("Unsupported group by $group->{KEY}");
- next;
- }
-
- @values = $self->loc('(no value)') unless @values;
- $value = \@values;
- }
- elsif ( $group->{INFO} eq 'CustomField' ) {
- my ($id) = $group->{SUBKEY} =~ /{(\d+)}/;
- my $values = $ticket->CustomFieldValues($id);
- if ( $values->Count ) {
- $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ];
- }
- else {
- $value = $self->loc('(no value)');
- }
- }
- elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) {
- my $business_time = $1;
-
- if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) {
- my $start = $1;
- my $end = $2;
- my $start_method = $start . 'Obj';
- my $end_method = $end . 'Obj';
- if ( $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0 ) {
- my $seconds;
-
- if ($business_time) {
- $seconds = $ticket->CustomDateRange(
- '',
- { value => "$end - $start",
- business_time => 1,
- format => sub { $_[0] },
- }
- );
- }
- else {
- $seconds = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
- }
-
- if ( $group->{SUBKEY} eq 'Default' ) {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show},
- Short => $group->{META}{Short},
- MaxUnit => $business_time ? 'hour' : 'year',
- );
- }
- else {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show} // 3,
- Short => $group->{META}{Short} // 1,
- MaxUnit => lc $group->{SUBKEY},
- MinUnit => lc $group->{SUBKEY},
- Unit => lc $group->{SUBKEY},
- );
- }
- }
-
- if ( $business_time ) {
- push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
- }
- }
- else {
- my %ranges = RT::Ticket->CustomDateRanges;
- if ( my $spec = $ranges{$group->{FIELD}} ) {
- if ( $group->{SUBKEY} eq 'Default' ) {
- $value = $ticket->CustomDateRange( $group->{FIELD}, $spec );
- }
- else {
- my $seconds = $ticket->CustomDateRange( $group->{FIELD},
- { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } );
-
- if ( defined $seconds ) {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $seconds,
- Show => $group->{META}{Show} // 3,
- Short => $group->{META}{Short} // 1,
- MaxUnit => lc $group->{SUBKEY},
- MinUnit => lc $group->{SUBKEY},
- Unit => lc $group->{SUBKEY},
- );
- }
- }
- if ( ref $spec && $spec->{business_time} ) {
- # 1 means the corresponding one in SLA, which $bh already holds
- $bh = $spec->{business_time} unless $spec->{business_time} eq '1';
- push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
- }
- }
- }
-
- $value //= $self->loc('(no value)');
- }
- else {
- RT->Logger->error("Unsupported group by $group->{KEY}");
- next;
- }
- push @keys, $value;
- }
- push @keys, @extra_keys;
-
- # @keys could contain arrayrefs, so we need to expand it.
- # e.g. "open", [ "root", "foo" ], "General" )
- # will be expanded to:
- # "open", "root", "General"
- # "open", "foo", "General"
-
- my @all_keys;
- for my $key (@keys) {
- if ( ref $key eq 'ARRAY' ) {
- if (@all_keys) {
- my @new_all_keys;
- for my $keys ( @all_keys ) {
- push @new_all_keys, [ @$keys, $_ ] for @$key;
- }
- @all_keys = @new_all_keys;
- }
- else {
- push @all_keys, [$_] for @$key;
- }
- }
- else {
- if (@all_keys) {
- @all_keys = map { [ @$_, $key ] } @all_keys;
- }
- else {
- push @all_keys, [$key];
- }
- }
- }
-
- my @fields = grep { $_->{TYPE} eq 'statistic' }
- map { $self->ColumnInfo($_) } $self->ColumnsList;
-
- while ( my $field = shift @fields ) {
- for my $keys (@all_keys) {
- my $key = join ';;;', @$keys;
- if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) {
- $info{$key}{ $field->{NAME} }++;
- }
- elsif ( $field->{NAME} =~ /^postfunction/ ) {
- if ( $field->{MAP} ) {
- my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/;
- for my $item ( values %{ $field->{MAP} } ) {
- push @fields,
- {
- NAME => $item->{NAME},
- FIELD => $item->{FIELD},
- INFO => [.
- '', $meta_type,
- $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '',
- @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ],
- ],
- };
- }
- }
- }
- elsif ( $field->{INFO}[1] eq 'Time' ) {
- if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) {
- my $method = $1;
- my $type = $field->{INFO}[2];
- my $name = lc $field->{NAME};
-
- $info{$key}{$name}
- = $self->_CalculateTime( $type, $ticket->$method * 60, $info{$key}{$name} ) || 0;
- }
- else {
- RT->Logger->error("Unsupported field $field->{NAME}");
- }
- }
- elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) {
- my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} };
- my $name = lc $field->{NAME};
- $info{$key}{$name} ||= 0;
-
- my $start_method = $start . 'Obj';
- my $end_method = $end . 'Obj';
- next unless $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0;
-
- my $value;
- if ($extra_info->{business_time}) {
- $value = $ticket->CustomDateRange(
- '',
- { value => "$end - $start",
- business_time => $extra_info->{business_time},
- format => sub { return $_[0] },
- }
- );
- }
- else {
- $value = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
- }
-
- $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
- }
- elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) {
- my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} };
- my $name = lc $field->{NAME};
- $info{$key}{$name} ||= 0;
-
- my $value;
- my %ranges = RT::Ticket->CustomDateRanges;
- if ( my $spec = $ranges{$range_name} ) {
- $value = $ticket->CustomDateRange(
- $range_name,
- {
- ref $spec eq 'HASH' ? %$spec : ( value => $spec ),
- format => sub { $_[0] },
- }
- );
- }
- $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
- }
- else {
- RT->Logger->error("Unsupported field $field->{INFO}[1]");
- }
- }
- }
-
- for my $keys (@all_keys) {
- my $key = join ';;;', @$keys;
- push @{ $info{$key}{ids} }, $ticket->id;
- }
- }
-
- # Make generated results real SB results
- for my $key ( keys %info ) {
- my @keys = split /;;;/, $key;
- my $row;
- for my $group ( @groups ) {
- $row->{lc $group->{NAME}} = shift @keys;
- }
- for my $field ( keys %{ $info{$key} } ) {
- my $value = $info{$key}{$field};
- if ( ref $value eq 'HASH' && $value->{calculate} ) {
- $row->{$field} = $value->{calculate}->($value);
- }
- else {
- $row->{$field} = $info{$key}{$field};
- }
- }
- my $item = $self->NewItem();
-
- # Has extra css info
- for my $key (@keys) {
- if ( $key =~ /(.+) => (.+)/ ) {
- $row->{_css_class}{$1} = $2;
- }
- }
-
- $item->LoadFromHash($row);
- $self->AddRecord($item);
- }
- $self->{must_redo_search} = 0;
- $self->{is_limited} = 1;
- $self->PostProcessRecords;
-
- return;
- }
-
- $self->SUPER::_DoSearch( @_ );
- if ( $self->{'must_redo_search'} ) {
- $RT::Logger->crit(
-"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
- );
- }
- else {
- $self->PostProcessRecords;
- }
-}
-
=head2 _FieldToFunction FIELD

-Returns a tuple of the field or a database function to allow grouping on that
-field.
+Returns a tuple of the field or a database function to allow grouping on that field.

=cut

@@ -1186,28 +678,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;

@@ -1767,36 +1237,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} @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;
@@ -1804,7 +1269,7 @@ sub _SetupCustomDateRanges {
}

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

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

return 1;
}
@@ -1835,13 +1299,84 @@ 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;
+}
+
+sub _RoleGroupClass {
+ my $self = shift;
+ my $collection_class = ref $self || $self;
+ $collection_class =~ s!(?<=RT::)Report::!!;
+ return $collection_class->_SingularClass;
+}
+
+sub _SingularClass {
+ my $self = shift;
+ return (ref $self || $self) . '::Entry';
+}
+
+
RT::Base->_ImportOverlays();

1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Entry.pm
similarity index 90%
copy from lib/RT/Report/Tickets/Entry.pm
copy to lib/RT/Report/Entry.pm
index 1dc357c10c..6782bf997b 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -46,13 +46,29 @@
#
# END BPS TAGGED BLOCK }}}

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

use warnings;
use strict;

use base qw/RT::Record/;

+=head1 NAME
+
+RT::Report::Entry - Base class of each entry in RT search charts
+
+=head1 DESCRIPTION
+
+This class defines fundamental bits of code that each real entry in
+search charts like L<RT::Report::Tickets::Entry> can subclass from.
+
+Subclasses generally just need to follow the class name convension, e.g.
+the entry class for L<RT::Report::Tickets> is L<RT::Report::Tickets::Entry>.
+
+=head1 METHODS
+
+=cut
+
# XXX TODO: how the heck do we acl a report?
sub CurrentUserHasRight {1}

@@ -97,11 +113,15 @@ sub RawValue {
}

sub ObjectType {
- return 'RT::Ticket';
+ my $self = shift;
+ my $report_class = ref $self || $self;
+ $report_class =~ s!::Entry$!!;
+ return $report_class->_RoleGroupClass;
}

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 9532acedb9..53d9424b55 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -48,13 +48,24 @@

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);
+
+=head1 NAME
+
+RT::Report::Tickets - Ticket search charts
+
+=head1 DESCRIPTION
+
+This is the backend class for ticket search charts.
+
+=head1 METHOD
+
+=cut

__PACKAGE__->RegisterCustomFieldJoin(@$_) for
[ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
@@ -100,280 +111,6 @@ our @GROUPINGS = (

SLA => 'Enum', #loc_left_pair
);
-our %GROUPINGS;
-
-our %GROUPINGS_META = (
- Queue => {
- Display => sub {
- my $self = shift;
- my %args = (@_);
-
- my $queue = RT::Queue->new( $self->CurrentUser );
- $queue->Load( $args{'VALUE'} );
- return $queue->Name;
- },
- Localize => 1,
- Distinct => 1,
- },
- Priority => {
- Sort => 'numeric raw',
- Distinct => 1,
- },
- User => {
- SubFields => [.grep RT::User->_Accessible($_, "public"), qw(
- Name RealName NickName
- EmailAddress
- Organization
- Lang City Country Timezone
- )],
- Function => 'GenerateUserFunction',
- Distinct => 1,
- },
- Watcher => {
- SubFields => sub {
- my $self = shift;
- my $args = shift;
-
- my %fields = (
- user => [. grep RT::User->_Accessible( $_, "public" ),
- qw( Name RealName NickName EmailAddress Organization Lang City Country Timezone) ],
- principal => [ grep RT::User->_Accessible( $_, "public" ), qw( Name ) ],
- );
-
- my @res;
- if ( $args->{key} =~ /^CustomRole/ ) {
- my $queues = $args->{'Queues'};
- if ( !$queues && $args->{'Query'} ) {
- require RT::Interface::Web::QueryBuilder::Tree;
- my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
- $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
- $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
- }
- return () unless $queues;
-
- my $crs = RT::CustomRoles->new( $self->CurrentUser );
- for my $id ( keys %$queues ) {
- my $queue = RT::Queue->new( $self->CurrentUser );
- $queue->Load($id);
- next unless $queue->id;
-
- $crs->LimitToObjectId( $queue->id );
- }
- while ( my $cr = $crs->Next ) {
- for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) {
- push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field";
- }
- }
- }
- else {
- for my $field ( @{ $fields{principal} } ) {
- push @res, [ $args->{key}, $field ], "$args->{key}.$field";
- }
- }
- return @res;
- },
- Function => 'GenerateWatcherFunction',
- Label => sub {
- my $self = shift;
- my %args = (@_);
-
- my $key;
- if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) {
- my $id = $1;
- my $cr = RT::CustomRole->new( $self->CurrentUser );
- $cr->Load($id);
- $key = $cr->Name;
- }
- else {
- $key = $args{KEY};
- }
- return join ' ', $key, $args{SUBKEY};
- },
- Display => sub {
- my $self = shift;
- my %args = (@_);
- # VALUE could be "(no value)" from perl level calculation
- if ( $args{FIELD} eq 'id' && ($args{'VALUE'} // '') !~ /\D/ ) {
- my $princ = RT::Principal->new( $self->CurrentUser );
- $princ->Load( $args{'VALUE'} ) if $args{'VALUE'};
- return $self->loc('(no value)') unless $princ->Id;
- return $princ->IsGroup ? $self->loc( 'Group: [_1]', $princ->Object->Name ) : $princ->Object->Name;
- }
- else {
- return $args{VALUE};
- }
- },
- Distinct => sub {
- my $self = shift;
- my %args = @_;
- if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) {
- my $id = $1;
- my $obj = RT::CustomRole->new( RT->SystemUser );
- $obj->Load( $id );
- if ( $obj->MaxValues == 1 ) {
- return 1;
- }
- else {
- return 0;
- }
- }
- return 0;
- },
- },
- Date => {
- SubFields => [.qw(
- Time
- Hourly Hour
- Date Daily
- DayOfWeek Day DayOfMonth DayOfYear
- Month Monthly
- Year Annually
- WeekOfYear
- )], # loc_qw
- StrftimeFormat => {
- Time => '%T',
- Hourly => '%Y-%m-%d %H',
- Hour => '%H',
- Date => '%F',
- Daily => '%F',
- DayOfWeek => '%w',
- Day => '%F',
- DayOfMonth => '%d',
- DayOfYear => '%j',
- Month => '%m',
- Monthly => '%Y-%m',
- Year => '%Y',
- Annually => '%Y',
- WeekOfYear => '%W',
- },
- Function => 'GenerateDateFunction',
- Display => sub {
- my $self = shift;
- my %args = (@_);
-
- my $raw = $args{'VALUE'};
- return $raw unless defined $raw;
-
- if ( $args{'SUBKEY'} eq 'DayOfWeek' ) {
- return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]);
- }
- elsif ( $args{'SUBKEY'} eq 'Month' ) {
- return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]);
- }
- return $raw;
- },
- Sort => 'raw',
- Distinct => 1,
- },
- CustomField => {
- SubFields => sub {
- my $self = shift;
- my $args = shift;
-
-
- my $queues = $args->{'Queues'};
- if ( !$queues && $args->{'Query'} ) {
- require RT::Interface::Web::QueryBuilder::Tree;
- my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
- $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
- $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
- }
- return () unless $queues;
-
- my @res;
-
- my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
- foreach my $id (keys %$queues) {
- my $queue = RT::Queue->new( $self->CurrentUser );
- $queue->Load($id);
- next unless $queue->id;
- $CustomFields->SetContextObject( $queue ) if keys %$queues == 1;
- $CustomFields->LimitToQueue($queue->id);
- }
- $CustomFields->LimitToGlobal;
- while ( my $CustomField = $CustomFields->Next ) {
- push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
- }
- return @res;
- },
- Function => 'GenerateCustomFieldFunction',
- Label => sub {
- my $self = shift;
- my %args = (@_);
-
- my ($cf) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
- if ( $cf =~ /^\d+$/ ) {
-
- # When we render label in charts, the cf could surely be
- # seen by current user(SubFields above checks rights), but
- # we can't use current user to load cf here because the
- # right might be granted at queue level and it's not
- # straightforward to add a related queue as context object
- # here. That's why we use RT->SystemUser here instead.
-
- my $obj = RT::CustomField->new( RT->SystemUser );
- $obj->Load( $cf );
- $cf = $obj->Name;
- }
-
- return 'Custom field [_1]', $cf;
- },
- Distinct => sub {
- my $self = shift;
- my %args = @_;
- if ( $args{SUBKEY} =~ /\{(\d+)\}/ ) {
- my $id = $1;
- my $obj = RT::CustomField->new( RT->SystemUser );
- $obj->Load( $id );
- if ( $obj->MaxValues == 1 ) {
- return 1;
- }
- else {
- return 0;
- }
- }
- return 0;
- },
- },
- Enum => {
- Localize => 1,
- Distinct => 1,
- },
- Duration => {
- SubFields => [ qw/Default Hour Day Week Month Year/ ],
- Localize => 1,
- Short => 0,
- Show => 1,
- Sort => 'duration',
- Distinct => 1,
- },
- DurationInBusinessHours => {
- SubFields => [ qw/Default Hour/ ],
- Localize => 1,
- Short => 0,
- Show => 1,
- Sort => 'duration',
- Distinct => 1,
- Display => sub {
- my $self = shift;
- my %args = (@_);
- my $value = $args{VALUE};
- my $format = $args{FORMAT} || 'text';
- if ( $format eq 'html' ) {
- RT::Interface::Web::EscapeHTML(\$value);
- my $css_class;
- if ( my $style = $self->__Value('_css_class') ) {
- $css_class = $style->{$args{NAME}};
- };
- return $value unless $css_class;
- return qq{<span class="$css_class">$value</span>};
- }
- else {
- return $value;
- }
- },
- },
-);

# loc'able strings below generated with (s/loq/loc/):
# perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
@@ -473,201 +210,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 = (
@@ -713,108 +255,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
@@ -829,8 +277,12 @@ sub SetupGroupings {

=head2 _DoSearch

-Subclass _DoSearch from our parent so we can go through and add in empty
-columns if it makes sense
+Subclass _DoSearch from our parent so we can go through and add in empty
+columns if it makes sense.
+
+Besides it, for cases where GroupBy/Calculation couldn't be implemented via
+SQL, we have to implement it in Perl, like business hours, time duration,
+custom date ranges, etc.

=cut

@@ -851,7 +303,6 @@ sub _DoSearch {
my $bh = $ticket->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $ticket->SLA }{BusinessHours} : '';

my @keys;
- my $max = 1;
my @extra_keys;
my %css_class;
for my $group ( @groups ) {
@@ -863,7 +314,7 @@ sub _DoSearch {
if ( my $obj = $ticket->$method ) {
if ( $group->{INFO} eq 'Date' ) {
if ( $obj->Unix > 0 ) {
- $value = $obj->Strftime( $GROUPINGS_META{Date}{StrftimeFormat}{ $group->{SUBKEY} },
+ $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} },
Timezone => 'user' );
}
else {
@@ -1152,41 +603,9 @@ sub _DoSearch {
}

$self->SUPER::_DoSearch( @_ );
- if ( $self->{'must_redo_search'} ) {
- $RT::Logger->crit(
-"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
- );
- }
- else {
- $self->PostProcessRecords;
- }
+ $self->_PostSearch();
}

-=head2 _FieldToFunction FIELD
-
-Returns a tuple of the field or a database function to allow grouping on that
-field.
-
-=cut
-
-sub _FieldToFunction {
- my $self = shift;
- my %args = (@_);
-
- $args{'FIELD'} ||= $args{'KEY'};
-
- my $meta = $GROUPINGS_META{ $self->_GroupingType( $args{'KEY'} ) };
- return ('FUNCTION' => 'NULL') unless $meta;
-
- return %args unless $meta->{'Function'};
-
- my $code = $self->FindImplementationCode( $meta->{'Function'} );
- return ('FUNCTION' => 'NULL') unless $code;
-
- return $code->( $self, %args );
-}
-
-
# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we
# don't want.
sub Next {
@@ -1195,653 +614,12 @@ sub Next {

}

-sub NewItem {
- my $self = shift;
- my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
- $res->{'report'} = $self;
- weaken $res->{'report'};
- return $res;
-}
-
-# This is necessary since normally NewItem (above) is used to intuit the
-# correct class. However, since we're abusing a subclass, it's incorrect.
-sub _RoleGroupClass { "RT::Ticket" }
-sub _SingularClass { "RT::Report::Tickets::Entry" }
-
-sub SortEntries {
- my $self = shift;
-
- $self->_DoSearch if $self->{'must_redo_search'};
- return unless $self->{'items'} && @{ $self->{'items'} };
-
- my @groups =
- grep $_->{'TYPE'} eq 'grouping',
- map $self->ColumnInfo($_),
- $self->ColumnsList;
- return unless @groups;
-
- my @SORT_OPS;
- my $by_multiple = sub ($$) {
- for my $f ( @SORT_OPS ) {
- my $r = $f->($_[0], $_[1]);
- return $r if $r;
- }
- };
- my @data = map [$_], @{ $self->{'items'} };
-
- for ( my $i = 0; $i < @groups; $i++ ) {
- my $group_by = $groups[$i];
- my $idx = $i+1;
-
- my $order = $group_by->{'META'}{Sort} || 'label';
- my $method = $order =~ /label$/ ? 'LabelValue' : 'RawValue';
-
- unless ($order =~ /^numeric/) {
- # Traverse the values being used for labels.
- # If they all look like numbers or undef, flag for a numeric sort.
- my $looks_like_number = 1;
- foreach my $item (@data){
- my $label = $item->[0]->$method($group_by->{'NAME'});
-
- $looks_like_number = 0
- unless (not defined $label)
- or Scalar::Util::looks_like_number( $label );
- }
- $order = "numeric $order" if $looks_like_number;
- }
-
- if ( $order eq 'label' ) {
- push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
- $method = 'LabelValue';
- }
- elsif ( $order eq 'numeric label' ) {
- my $nv = $self->loc("(no value)");
- # Sort the (no value) elements first, by comparing for them
- # first, and falling back to a numeric sort on all other
- # values.
- push @SORT_OPS, sub {
- (($_[0][$idx] ne $nv) <=> ($_[1][$idx] ne $nv))
- || ( $_[0][$idx] <=> $_[1][$idx] ) };
- $method = 'LabelValue';
- }
- elsif ( $order eq 'raw' ) {
- push @SORT_OPS, sub { ($_[0][$idx]//'') cmp ($_[1][$idx]//'') };
- $method = 'RawValue';
- }
- elsif ( $order eq 'numeric raw' ) {
- push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
- $method = 'RawValue';
- }
- elsif ( $order eq 'duration' ) {
- push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
- $method = 'DurationValue';
- } else {
- $RT::Logger->error("Unknown sorting function '$order'");
- next;
- }
- $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data;
- }
- $self->{'items'} = [
- map $_->[0],
- sort $by_multiple @data
- ];
-}
-
-sub PostProcessRecords {
- my $self = shift;
-
- my $info = $self->{'column_info'};
- foreach my $column ( values %$info ) {
- next unless $column->{'TYPE'} eq 'statistic';
- if ( $column->{'META'}{'Calculate'} ) {
- $self->CalculatePostFunction( $column );
- }
- elsif ( $column->{'META'}{'SubValues'} ) {
- $self->MapSubValues( $column );
- }
- }
-}
-
-sub CalculatePostFunction {
- my $self = shift;
- my $info = shift;
-
- my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} );
- unless ( $code ) {
- # TODO: fill in undefs
- return;
- }
-
- my $column = $info->{'NAME'};
-
- my $base_query = $self->Query;
- foreach my $item ( @{ $self->{'items'} } ) {
- $item->{'values'}{ lc $column } = $code->(
- $self,
- Query => join(
- ' AND ', map "($_)", grep defined && length, $base_query, $item->Query,
- ),
- );
- $item->{'fetched'}{ lc $column } = 1;
- }
-}
-
-sub MapSubValues {
- my $self = shift;
- my $info = shift;
-
- my $to = $info->{'NAME'};
- my $map = $info->{'MAP'};
-
- foreach my $item ( @{ $self->{'items'} } ) {
- my $dst = $item->{'values'}{ lc $to } = { };
- while (my ($k, $v) = each %{ $map } ) {
- $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} };
- # This mirrors the logic in RT::Record::__Value When that
- # ceases tp use the UTF-8 flag as a character/byte
- # distinction from the database, this can as well.
- utf8::decode( $dst->{ $k } )
- if defined $dst->{ $k }
- and not utf8::is_utf8( $dst->{ $k } );
- delete $item->{'fetched'}{ lc $v->{'NAME'} };
- }
- $item->{'fetched'}{ lc $to } = 1;
- }
-}
-
-sub GenerateDateFunction {
- my $self = shift;
- my %args = @_;
-
- my $tz;
- if ( RT->Config->Get('ChartsTimezonesInDB') ) {
- my $to = $self->CurrentUser->UserObj->Timezone
- || RT->Config->Get('Timezone');
- $tz = { From => 'UTC', To => $to }
- if $to && lc $to ne 'utc';
- }
-
- $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
- Type => $args{'SUBKEY'},
- Field => $self->NotSetDateToNullFunction,
- Timezone => $tz,
- );
- return %args;
-}
-
-sub GenerateCustomFieldFunction {
- my $self = shift;
- my %args = @_;
-
- my ($name) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
- my $cf = RT::CustomField->new( $self->CurrentUser );
- $cf->Load($name);
- unless ( $cf->id ) {
- $RT::Logger->error("Couldn't load CustomField #$name");
- @args{qw(FUNCTION FIELD)} = ('NULL', undef);
- } else {
- my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf);
- @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content');
- }
- return %args;
-}
-
-sub GenerateUserFunction {
- my $self = shift;
- my %args = @_;
-
- my $column = $args{'SUBKEY'} || 'Name';
- my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"}
- ||= $self->Join(
- TYPE => 'LEFT',
- ALIAS1 => 'main',
- FIELD1 => $args{'FIELD'},
- TABLE2 => 'Users',
- FIELD2 => 'id',
- );
- @args{qw(ALIAS FIELD)} = ($u_alias, $column);
- return %args;
-}
-
-sub GenerateWatcherFunction {
- my $self = shift;
- my %args = @_;
-
- my $type = $args{'FIELD'};
- $type = '' if $type eq 'Watcher';
-
- my $single_role;
-
- if ( $type =~ s!^CustomRole\.\{(\d+)\}!RT::CustomRole-$1! ) {
- my $id = $1;
- my $cr = RT::CustomRole->new( $self->CurrentUser );
- $cr->Load($id);
- $single_role = 1 if $cr->MaxValues;
- }
-
- my $column = $single_role ? $args{'SUBKEY'} || 'Name' : 'id';
-
- my $alias = $self->{"_sql_report_watcher_alias_$type"};
- unless ( $alias ) {
- my $groups = $self->_RoleGroupsJoin(Name => $type);
- my $group_members = $self->Join(
- TYPE => 'LEFT',
- ALIAS1 => $groups,
- FIELD1 => 'id',
- TABLE2 => 'GroupMembers',
- FIELD2 => 'GroupId',
- ENTRYAGGREGATOR => 'AND',
- );
- $alias = $self->Join(
- TYPE => 'LEFT',
- ALIAS1 => $group_members,
- FIELD1 => 'MemberId',
- TABLE2 => $single_role ? 'Users' : 'Principals',
- FIELD2 => 'id',
- );
- $self->{"_sql_report_watcher_alias_$type"} = $alias;
- }
- @args{qw(ALIAS FIELD)} = ($alias, $column);
-
- return %args;
-}
-
-sub DurationAsString {
- my $self = shift;
- my %args = @_;
- my $v = $args{'VALUE'};
- my $max_unit = $args{INFO} && ref $args{INFO}[-1] && $args{INFO}[-1]{business_time} ? 'hour' : 'year';
- my $format = $args{FORMAT} || 'text';
-
- my $css_class;
- if ( $format eq 'html'
- && $self->can('__Value')
- && $args{INFO}
- && ref $args{INFO}[-1]
- && $args{INFO}[-1]{business_time} )
- {
-
- # 1 means business hours in SLA, its css is already generated and saved in _css_class.
- if ( $args{INFO}[-1]{business_time} eq '1' ) {
- my $style = $self->__Value('_css_class');
- my $field;
- if ( $args{INFO}[1] =~ /^CustomDateRange/ ) {
- $field = $args{INFO}[-2];
- }
- elsif ( $args{INFO}[1] =~ /^DateTimeInterval/ ) {
- $field = join ' to ', $args{INFO}[-3], $args{INFO}[-2];
- }
-
- $css_class = $style->{$field} if $style && $field;
- }
- else {
- $css_class = 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $args{INFO}[-1]{business_time} )
- }
- }
-
- unless ( ref $v ) {
- my $value;
- if ( defined $v && length $v ) {
- $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
- $v,
- Show => 3,
- Short => 1,
- MaxUnit => $max_unit,
- );
- }
- else {
- $value = $self->loc("(no value)");
- }
-
- if ( $format eq 'html' ) {
- RT::Interface::Web::EscapeHTML(\$value);
- return $value unless $css_class;
- return qq{<span class="$css_class">$value</span>};
- }
- else {
- return $value;
- }
-
- }
-
- my $date = RT::Date->new( $self->CurrentUser );
- my %res = %$v;
- foreach my $e ( values %res ) {
- $e = $date->DurationAsString( $e, Short => 1, Show => 3, MaxUnit => $max_unit )
- if defined $e && length $e;
- $e = $self->loc("(no value)") unless defined $e && length $e;
- }
-
- if ( $format eq 'html' ) {
- for my $key ( keys %res ) {
- RT::Interface::Web::EscapeHTML(\$res{$key});
- next unless $css_class;
- $res{$key} = qq{<span class="$css_class">$res{$key}</span>};
- }
- }
- return \%res;
-}
-
-sub LabelValueCode {
- my $self = shift;
- my $name = shift;
-
- my $display = $self->ColumnInfo( $name )->{'META'}{'Display'};
- return undef unless $display;
- return $self->FindImplementationCode( $display );
-}
-
-
-sub FindImplementationCode {
- my $self = shift;
- my $value = shift;
- my $silent = shift;
-
- my $code;
- unless ( $value ) {
- $RT::Logger->error("Value is not defined. Should be method name or code reference")
- unless $silent;
- return undef;
- }
- elsif ( !ref $value ) {
- $code = $self->can( $value );
- unless ( $code ) {
- $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" )
- unless $silent;
- return undef;
- }
- }
- elsif ( ref( $value ) eq 'CODE' ) {
- $code = $value;
- }
- else {
- $RT::Logger->error("$value is not method name or code reference")
- unless $silent;
- return undef;
- }
- return $code;
-}
-
-sub Serialize {
- my $self = shift;
-
- my %clone = %$self;
-# current user, handle and column_info
- delete @clone{'user', 'DBIxHandle', 'column_info'};
- $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ];
- $clone{'column_info'} = {};
- while ( my ($k, $v) = each %{ $self->{'column_info'} } ) {
- $clone{'column_info'}{$k} = { %$v };
- delete $clone{'column_info'}{$k}{'META'};
- }
- return \%clone;
-}
-
-sub Deserialize {
- my $self = shift;
- my $data = shift;
-
- $self->CleanSlate;
- %$self = (%$self, %$data);
-
- $self->{'items'} = [.
- map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r }
- @{ $self->{'items'} }
- ];
- foreach my $e ( values %{ $self->{column_info} } ) {
- $e->{'META'} = $e->{'TYPE'} eq 'grouping'
- ? $GROUPINGS_META{ $e->{'INFO'} }
- : $STATISTICS_META{ $e->{'INFO'}[1] }
- }
-}
-
-
-sub FormatTable {
- my $self = shift;
- my %columns = @_;
-
- my (@head, @body, @footer);
-
- @head = ({ cells => []});
- foreach my $column ( @{ $columns{'Groups'} } ) {
- push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) };
- }
-
- my $i = 0;
- while ( my $entry = $self->Next ) {
- $body[ $i ] = { even => ($i+1)%2, cells => [] };
- $i++;
- }
- @footer = ({ even => ++$i%2, cells => []}) if $self->{_distinct_results};
-
- my $g = 0;
- foreach my $column ( @{ $columns{'Groups'} } ) {
- $i = 0;
- my $last;
- while ( my $entry = $self->Next ) {
- my $value = $entry->LabelValue( $column, 'html' );
- if ( !$last || $last->{'value'} ne $value ) {
- push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value };
- $last->{even} = $g++ % 2
- unless $column eq $columns{'Groups'}[-1];
- }
- else {
- $i++;
- $last->{rowspan} = ($last->{rowspan}||1) + 1;
- }
- }
- }
- push @{ $footer[0]{'cells'} }, {
- type => 'label',
- value => $self->loc('Total'),
- colspan => scalar @{ $columns{'Groups'} },
- } if $self->{_distinct_results};
-
- my $pick_color = do {
- my @colors = RT->Config->Get("ChartColors");
- sub { $colors[ $_[0] % @colors - 1 ] }
- };
-
- my $function_count = 0;
- foreach my $column ( @{ $columns{'Functions'} } ) {
- $i = 0;
-
- my $info = $self->ColumnInfo( $column );
-
- my @subs = ('');
- if ( $info->{'META'}{'SubValues'} ) {
- @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->(
- $self
- );
- }
-
- my %total;
- unless ( $info->{'META'}{'NoTotals'} ) {
- while ( my $entry = $self->Next ) {
- my $raw = $entry->RawValue( $column ) || {};
- $raw = { '' => $raw } unless ref $raw;
- $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
- }
- @subs = grep $total{$_}, @subs
- unless $info->{'META'}{'NoHideEmpty'};
- }
-
- my $label = $self->Label( $column );
-
- unless (@subs) {
- while ( my $entry = $self->Next ) {
- push @{ $body[ $i++ ]{'cells'} }, {
- type => 'value',
- value => undef,
- query => $entry->Query,
- };
- }
- push @{ $head[0]{'cells'} }, {
- type => 'head',
- value => $label,
- rowspan => scalar @head,
- color => $pick_color->(++$function_count),
- };
- push @{ $footer[0]{'cells'} }, { type => 'value', value => undef } if $self->{_distinct_results};
- next;
- }
-
- if ( @subs > 1 && @head == 1 ) {
- $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} };
- }
-
- if ( @subs == 1 ) {
- push @{ $head[0]{'cells'} }, {
- type => 'head',
- value => $label,
- rowspan => scalar @head,
- color => $pick_color->(++$function_count),
- };
- } else {
- push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs };
- push @{ $head[1]{'cells'} }, { type => 'head', value => $_, color => $pick_color->(++$function_count) }
- foreach @subs;
- }
-
- while ( my $entry = $self->Next ) {
- my $query = $entry->Query;
- my $value = $entry->LabelValue( $column, 'html' ) || {};
- $value = { '' => $value } unless ref $value;
- foreach my $e ( @subs ) {
- push @{ $body[ $i ]{'cells'} }, {
- type => 'value',
- value => $value->{ $e },
- query => $query,
- };
- }
- $i++;
- }
-
- next unless $self->{_distinct_results};
- unless ( $info->{'META'}{'NoTotals'} ) {
- my $total_code = $self->LabelValueCode( $column );
- foreach my $e ( @subs ) {
- my $total = $total{ $e };
- $total = $total_code->( $self, %$info, VALUE => $total, FORMAT => 'html' )
- if $total_code;
- push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
- }
- }
- else {
- foreach my $e ( @subs ) {
- push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
- }
- }
- }
-
- return thead => \@head, tbody => \@body, tfoot => \@footer;
-}
-
-sub _CalculateTime {
- my $self = shift;
- my ( $type, $value, $current ) = @_;
-
- return $current unless defined $value;
-
- if ( $type eq 'SUM' ) {
- $current += $value;
- }
- elsif ( $type eq 'AVG' ) {
- $current ||= {};
- $current->{total} += $value;
- $current->{count}++;
- $current->{calculate} ||= sub {
- my $item = shift;
- return sprintf '%.0f', $item->{total} / $item->{count};
- };
- }
- elsif ( $type eq 'MAX' ) {
- $current = $value unless $current && $current > $value;
- }
- elsif ( $type eq 'MIN' ) {
- $current = $value unless $current && $current < $value;
- }
- else {
- RT->Logger->error("Unsupported type $type");
- }
- return $current;
-}
-
sub new {
my $self = shift;
$self->_SetupCustomDateRanges;
return $self->SUPER::new(@_);
}

-
-sub _SetupCustomDateRanges {
- my $self = shift;
- my %names;
-
- # Remove old custom date range groupings
- for my $field ( grep {ref} @STATISTICS ) {
- if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) {
- $names{ $field->[2] } = 1;
- }
- }
-
- my ( @new_groupings, @new_statistics );
- while (@GROUPINGS) {
- my $name = shift @GROUPINGS;
- my $type = shift @GROUPINGS;
- if ( !$names{$name} ) {
- push @new_groupings, $name, $type;
- }
- }
-
- while (@STATISTICS) {
- my $key = shift @STATISTICS;
- my $info = shift @STATISTICS;
- my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/;
- unless ( $name && $names{$name} ) {
- push @new_statistics, $key, $info;
- }
- }
-
- # Add new ones
- my %ranges = RT::Ticket->CustomDateRanges;
- for my $name ( sort keys %ranges ) {
- my %extra_info;
- my $spec = $ranges{$name};
- if ( ref $spec && $spec->{business_time} ) {
- $extra_info{business_time} = $spec->{business_time};
- }
-
- push @new_groupings, $name => $extra_info{business_time} ? 'DurationInBusinessHours' : 'Duration';
- push @new_statistics,
- (
- "ALL($name)" => [ "Summary of $name", 'CustomDateRangeAll', $name, \%extra_info ],
- "SUM($name)" => [ "Total $name", 'CustomDateRange', 'SUM', $name, \%extra_info ],
- "AVG($name)" => [ "Average $name", 'CustomDateRange', 'AVG', $name, \%extra_info ],
- "MIN($name)" => [ "Minimum $name", 'CustomDateRange', 'MIN', $name, \%extra_info ],
- "MAX($name)" => [ "Maximum $name", 'CustomDateRange', 'MAX', $name, \%extra_info ],
- );
- }
-
- @GROUPINGS = @new_groupings;
- @STATISTICS = @new_statistics;
- %GROUPINGS = %STATISTICS = ();
-
- return 1;
-}
-
-sub _GroupingType {
- my $self = shift;
- my $key = shift or return;
- # keys for custom roles are like "CustomRole.{1}"
- $key = 'CustomRole' if $key =~ /^CustomRole/;
- return $GROUPINGS{$key};
-}
-
-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 1dc357c10c..9e34149a2d 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -51,159 +51,7 @@ package RT::Report::Tickets::Entry;
use warnings;
use strict;

-use base qw/RT::Record/;
-
-# XXX TODO: how the heck do we acl a report?
-sub CurrentUserHasRight {1}
-
-=head2 LabelValue
-
-If you're pulling a value out of this collection and using it as a label,
-you may want the "cleaned up" version. This includes scrubbing 1970 dates
-and ensuring that dates are in local not DB timezones.
-
-=cut
-
-sub LabelValue {
- my $self = shift;
- my $name = shift;
- my $format = shift || 'text';
-
- my $raw = $self->RawValue( $name, @_ );
- if ( my $code = $self->Report->LabelValueCode( $name ) ) {
- $raw = $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw, FORMAT => $format );
- return $self->loc('(no value)') unless defined $raw && length $raw;
- return $raw;
- }
-
- unless ( ref $raw ) {
- return $self->loc('(no value)') unless defined $raw && length $raw;
- return $self->loc($raw) if $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
- return $raw;
- } else {
- my $loc = $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
- my %res = %$raw;
- if ( $loc ) {
- $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res;
- $_ = $self->loc($_) foreach values %res;
- }
- $_ = $self->loc('(no value)') foreach grep !defined || !length, values %res;
- return \%res;
- }
-}
-
-sub RawValue {
- return (shift)->__Value( @_ );
-}
-
-sub ObjectType {
- return 'RT::Ticket';
-}
-
-sub CustomFieldLookupType {
- RT::Ticket->CustomFieldLookupType
-}
-
-sub Query {
- my $self = shift;
-
- if ( my $ids = $self->{values}{ids} ) {
- return join ' OR ', map "id=$_", @$ids;
- }
-
- my @parts;
- foreach my $column ( $self->Report->ColumnsList ) {
- my $info = $self->Report->ColumnInfo( $column );
- next unless $info->{'TYPE'} eq 'grouping';
-
- my $custom = $info->{'META'}{'Query'};
- if ( $custom and my $code = $self->Report->FindImplementationCode( $custom ) ) {
- push @parts, $code->( $self, COLUMN => $column, %$info );
- }
- else {
- my $field = join '.', grep $_, $info->{KEY}, $info->{SUBKEY};
- my $value = $self->RawValue( $column );
- my $op = '=';
- if ( defined $value ) {
- if ( $info->{INFO} eq 'Watcher' && $info->{FIELD} eq 'id' ) {
-
- # convert id to name
- my $princ = RT::Principal->new( $self->CurrentUser );
- $princ->Load($value);
- $value = $princ->Object->Name if $princ->Object;
- }
-
- unless ( $value =~ /^\d+$/ ) {
- $value =~ s/(['\\])/\\$1/g;
- $value = "'$value'";
- }
- }
- else {
- ($op, $value) = ('IS', 'NULL');
- }
- unless ( $field =~ /^[{}\w\.]+$/ ) {
- $field =~ s/(['\\])/\\$1/g;
- $field = "'$field'";
- }
- push @parts, "$field $op $value";
- }
- }
- return () unless @parts;
- return join ' AND ', map "($_)", grep defined && length, @parts;
-}
-
-sub Report {
- return $_[0]->{'report'};
-}
-
-sub DurationValue {
- my $self = shift;
- my $value = $self->__Value(@_);
-
- return 0 unless $value;
-
- my $number;
- my $unit;
- if ( $value =~ /([\d,]+)(?:s| second)/ ) {
- $number = $1;
- $unit = 1;
- }
- elsif ( $value =~ /([\d,]+)(?:m| minute)/ ) {
- $number = $1;
- $unit = $RT::Date::MINUTE;
- }
- elsif ( $value =~ /([\d,]+)(?:h| hour)/ ) {
- $number = $1;
- $unit = $RT::Date::HOUR;
- }
- elsif ( $value =~ /([\d,]+)(?:d| day)/ ) {
- $number = $1;
- $unit = $RT::Date::DAY;
- }
- elsif ( $value =~ /([\d,]+)(?:W| week)/ ) {
- $number = $1;
- $unit = $RT::Date::WEEK;
- }
- elsif ( $value =~ /([\d,]+)(?:M| month)/ ) {
- $number = $1;
- $unit = $RT::Date::MONTH;
- }
- elsif ( $value =~ /([\d,]+)(?:Y| year)/ ) {
- $number = $1;
- $unit = $RT::Date::YEAR;
- }
- else {
- return -.1; # Mark "(no value)" as -1 so it comes before 0
- }
-
- $number =~ s!,!!g;
- my $seconds = $number * $unit;
-
- if ( $value =~ /([<|>])/ ) {
- $seconds += $1 eq '<' ? -1 : 1;
- }
- return $seconds;
-}
+use base qw/RT::Report::Entry/;

RT::Base->_ImportOverlays();


commit 7ac429de584d5fa8def554c576f9505e3727eb4a
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 45af2a2668..9532acedb9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -720,7 +720,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 ) {
@@ -1838,6 +1838,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 c134f17601..798e223c39 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -267,6 +267,22 @@ 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;
+ if ( $class =~ s/(?<=^RT::)/Report::/ ) {
+ return $class;
+ }
+ return undef;
+}
+
=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 a86701b48e..2202f40406 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 d5cead1824..5b3a19afcc 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,10 @@ 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
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 cd15b50fa7..a106fc508e 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -46,9 +46,14 @@
%#
%# END BPS TAGGED BLOCK }}}
<%init>
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 +62,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), grep $_, @ExtraQueryParams );
+my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height Class ExtraQueryParams), grep $_, @ExtraQueryParams );
my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
SearchType => 'Chart',
SearchFields => [@search_fields],
@@ -147,6 +152,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
<form method="POST" 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 +171,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 +182,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 +193,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 +339,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 +349,5 @@ jQuery( function() {

<%ARGS>
@ExtraQueryParams => ()
+$Class => 'RT::Tickets'
</%ARGS>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index a6cd9e0ada..7079551846 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -50,11 +50,14 @@ $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
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 694d297b02..175a3c5680 100644
--- a/share/html/Search/Elements/ChartTable
+++ b/share/html/Search/Elements/ChartTable
@@ -48,6 +48,7 @@
<%ARGS>
%Table => ()
$Query => 'id > 0'
+$Class => 'RT::Tickets'
</%ARGS>
<%INIT>

diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index c9db81f1b9..7068315ec6 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -123,7 +123,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 7a794fea4c..ee1aeab4b4 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -72,8 +72,11 @@ 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
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 e5c7e77c13..c35512dbc0 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,9 @@ 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
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 ae8f6f1cc6..5f5500654c 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 f8bf45b7aa..dc72c4cddf 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 || ();
@@ -188,12 +189,12 @@ $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
+ or Abort( loc( "Couldn't find ReportClass for [_1]", $Class || 'RT::Tickets' ) );
+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 08c8be0110..1eef0033d6 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