[Rt-commit] rt branch 5.0/txn-search-chart created. rt-5.0.2-116-gf5cd8a14dc

BPS Git Server git at git.bestpractical.com
Wed Mar 30 19:29:25 UTC 2022


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  f5cd8a14dcec7d08588f7771a8dc479015a27382 (commit)

- Log -----------------------------------------------------------------
commit f5cd8a14dcec7d08588f7771a8dc479015a27382
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Mar 30 22:14:40 2022 +0800

    Test transaction charts

diff --git a/t/web/charting.t b/t/web/charting.t
index bb7ecb87b2..39320980dd 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -104,4 +104,13 @@ $m->get_ok( "/Search/Chart?Query=Requestor.Name LIKE 'root'" );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
 
+# Test txn charts
+$m->get_ok("/Search/Chart.html?Class=RT::Transactions&Query=Type=Create");
+$m->content_like( qr{<th[^>]*>Creator\s*</th>\s*<th[^>]*>Transaction count\s*</th>}, "Grouped by creator" );
+$m->content_like( qr{RT_System\s*</th>\s*<td[^>]*>\s*<a[^>]*>7</a>},                 "Found results in table" );
+$m->content_like( qr{<img src="/Search/Chart\?},                                     "Found image" );
+$m->get_ok("/Search/Chart?Class=RT::Transactions&Query=Type=Create");
+is( $m->content_type, "image/png" );
+ok( length( $m->content ), "Has content" );
+
 done_testing;
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index 9196c57683..5ed6b4964c 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -193,11 +193,24 @@ $m->submit_form(
 # We don't show saved message on page :/
 $m->content_contains("Save as New", 'saved first txn search' );
 
+$m->get_ok( $url . "/Search/Chart.html?Class=RT::Transactions&Query=" . 'id>1' );
+
+$m->submit_form(
+    form_name => 'SaveSearch',
+    fields    => {
+        SavedSearchDescription => 'first txn chart',
+        SavedSearchOwner       => 'RT::System-1',
+    },
+    button => 'SavedSearchSave',
+);
+$m->content_contains("Chart first txn chart saved", 'saved first txn chart' );
+
 $m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
 push(
     @{$args->{body}},
     "saved-" . $m->dom->find('[data-description="first chart"]')->first->attr('data-name'),
     "saved-" . $m->dom->find('[data-description="first txn search"]')->first->attr('data-name'),
+    "saved-" . $m->dom->find('[data-description="first txn chart"]')->first->attr('data-name'),
 );
 
 $res = $m->post(
@@ -211,5 +224,7 @@ $m->content_contains( 'Dashboard updated' );
 $m->get_ok($url);
 $m->text_contains('first chart');
 $m->text_contains('first txn search');
+$m->text_contains('first txn chart');
+$m->text_contains('Transaction count', 'txn chart content');
 
 done_testing;
diff --git a/t/web/saved_search_chart.t b/t/web/saved_search_chart.t
index 24c492e558..e51d4bc86d 100644
--- a/t/web/saved_search_chart.t
+++ b/t/web/saved_search_chart.t
@@ -198,4 +198,33 @@ diag "saving a chart without changing its config shows up on dashboards (I#31557
     is_deeply($search->GetParameter('ChartFunction'), ['COUNT'], 'chart correctly initialized with default ChartFunction');
 }
 
+diag 'testing transaction saved searches';
+{
+    $m->get_ok("/Search/Chart.html?Class=RT::Transactions&Query=Type=Create");
+    $m->submit_form(
+        form_name => 'SaveSearch',
+        fields    => {
+            SavedSearchDescription => 'txn chart 1',
+            SavedSearchOwner       => $owner,
+        },
+        button => 'SavedSearchSave',
+    );
+    $m->form_name('SaveSearch');
+    @saved_search_ids = $m->current_form->find_input('SavedSearchLoad')->possible_values;
+    shift @saved_search_ids;    # first value is blank
+    my $chart_without_updates_id = $saved_search_ids[0];
+    ok( $chart_without_updates_id, 'got a saved chart id' );
+    is( scalar @saved_search_ids, 1, 'got only one saved chart id' );
+
+    my ( $privacy, $user_id, $search_id ) = $chart_without_updates_id =~ /^(RT::User-(\d+))-SavedSearch-(\d+)$/;
+    my $user = RT::User->new( RT->SystemUser );
+    $user->Load($user_id);
+    is( $user->Name, 'root', 'loaded user' );
+    my $currentuser = RT::CurrentUser->new($user);
+
+    my $search = RT::SavedSearch->new($currentuser);
+    $search->Load( $privacy, $search_id );
+    is( $search->Name, 'txn chart 1', 'loaded search' );
+}
+
 done_testing;

commit 2d04fa783b2280cff2b2bea6b3ff4d4642a605ee
Author: sunnavy <sunnavy at 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 6d42200acb..5f79b3c641 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -724,6 +724,9 @@ sub BuildMainNav {
             elsif ( $class eq 'RT::Assets' ) {
                 $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" );
             }
+            elsif ( $class eq 'RT::Transactions' ) {
+                $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" );
+            }
 
             my $more = $current_search_menu->child( more => title => loc('Feeds') );
 
diff --git a/lib/RT/Report/Entry.pm b/lib/RT/Report/Entry.pm
index 76af268b92..8d8a596b40 100644
--- a/lib/RT/Report/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -56,6 +56,9 @@ use base qw/RT::Record/;
 # XXX TODO: how the heck do we acl a report?
 sub CurrentUserHasRight {1}
 
+# RT::Transactions::AddRecord calls CurrentUserCanSee
+sub CurrentUserCanSee {1}
+
 =head2 LabelValue
 
 If you're pulling a value out of this collection and using it as a label,
diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm
new file mode 100644
index 0000000000..6d0ffbfe6e
--- /dev/null
+++ b/lib/RT/Report/Transactions.pm
@@ -0,0 +1,116 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::Report::Transactions;
+
+use base qw/RT::Report RT::Transactions/;
+use RT::Report::Transactions::Entry;
+
+use strict;
+use warnings;
+use 5.010;
+
+our @GROUPINGS = (
+    Creator => 'User',    #loc_left_pair
+    Created => 'Date',    #loc_left_pair
+);
+
+# loc'able strings below generated with (s/loq/loc/):
+#   perl -MRT=-init -MRT::Report::Transactions -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Transactions::STATISTICS, 0, 2'
+#
+# loc("Transaction count")
+
+our @STATISTICS = ( COUNT => [ 'Transaction count', 'Count', 'id' ], );
+
+sub SetupGroupings {
+    my $self = shift;
+    my %args = (
+        Query    => undef,
+        GroupBy  => undef,
+        Function => undef,
+        @_
+    );
+
+    # Unlike tickets, UseSQLForACLChecks is not supported in transactions, thus we need to iterate transactions first
+    # to filter by rights, which is implemented in RT::Transactions::AddRecord
+    if ( $args{'Query'} ) {
+        my $txns = RT::Transactions->new( $self->CurrentUser );
+        # Currently we only support ticket transaction search.
+        $txns->FromSQL( "ObjectType='RT::Ticket' AND TicketType = 'ticket' AND ($args{'Query'})" );
+        $txns->Columns('id');
+
+        my @match = (0);
+        while ( my $row = $txns->Next ) {
+            push @match, $row->id;
+        }
+
+        $self->CleanSlate;
+        while ( @match > 1000 ) {
+            my @batch = splice( @match, 0, 1000 );
+            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
+        }
+        $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
+    }
+
+    return $self->SUPER::SetupGroupings(%args);
+}
+
+sub _DoSearch {
+    my $self = shift;
+    $self->SUPER::_DoSearch(@_);
+    $self->_PostSearch();
+}
+
+# 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::Transaction"}
+sub _SingularClass  {"RT::Report::Transactions::Entry"}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Report/Transactions/Entry.pm b/lib/RT/Report/Transactions/Entry.pm
new file mode 100644
index 0000000000..698dac223f
--- /dev/null
+++ b/lib/RT/Report/Transactions/Entry.pm
@@ -0,0 +1,60 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::Report::Transactions::Entry;
+
+use warnings;
+use strict;
+
+use base qw/RT::Report::Entry/;
+
+sub ObjectType { 'RT::Transaction' }
+
+RT::Base->_ImportOverlays();
+
+1;

commit 9dfef15159b30ea4a57203737169a80f0e145dad
Author: sunnavy <sunnavy at 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 4aa71696f4..a064ddc75d 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report.pm
@@ -46,61 +46,13 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::Report::Tickets;
-
-use base qw/RT::Tickets/;
-use RT::Report::Tickets::Entry;
+package RT::Report;
 
 use strict;
 use warnings;
 use 5.010;
 use Scalar::Util qw(weaken);
-
-__PACKAGE__->RegisterCustomFieldJoin(@$_) for
-    [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
-    [ "RT::Queue"       => sub {
-            # XXX: Could avoid join and use main.Queue with some refactoring?
-            return $_[0]->{_sql_aliases}{queues} ||= $_[0]->Join(
-                ALIAS1 => 'main',
-                FIELD1 => 'Queue',
-                TABLE2 => 'Queues',
-                FIELD2 => 'id',
-            );
-        }
-    ];
-
-our @GROUPINGS = (
-    Status => 'Enum',                   #loc_left_pair
-
-    Queue  => 'Queue',                  #loc_left_pair
-
-    InitialPriority => 'Priority',          #loc_left_pair
-    FinalPriority   => 'Priority',          #loc_left_pair
-    Priority        => 'Priority',          #loc_left_pair
-
-    Owner         => 'User',            #loc_left_pair
-    Creator       => 'User',            #loc_left_pair
-    LastUpdatedBy => 'User',            #loc_left_pair
-
-    Requestor     => 'Watcher',         #loc_left_pair
-    Cc            => 'Watcher',         #loc_left_pair
-    AdminCc       => 'Watcher',         #loc_left_pair
-    Watcher       => 'Watcher',         #loc_left_pair
-    CustomRole    => 'Watcher',
-
-    Created       => 'Date',            #loc_left_pair
-    Starts        => 'Date',            #loc_left_pair
-    Started       => 'Date',            #loc_left_pair
-    Resolved      => 'Date',            #loc_left_pair
-    Due           => 'Date',            #loc_left_pair
-    Told          => 'Date',            #loc_left_pair
-    LastUpdated   => 'Date',            #loc_left_pair
-
-    CF            => 'CustomField',     #loc_left_pair
-
-    SLA           => 'Enum',            #loc_left_pair
-);
-our %GROUPINGS;
+use RT::User;
 
 our %GROUPINGS_META = (
     Queue => {
@@ -356,106 +308,6 @@ our %GROUPINGS_META = (
     },
 );
 
-# loc'able strings below generated with (s/loq/loc/):
-#   perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
-#
-# loc("Ticket count")
-# loc("Summary of time worked")
-# loc("Total time worked")
-# loc("Average time worked")
-# loc("Minimum time worked")
-# loc("Maximum time worked")
-# loc("Summary of time estimated")
-# loc("Total time estimated")
-# loc("Average time estimated")
-# loc("Minimum time estimated")
-# loc("Maximum time estimated")
-# loc("Summary of time left")
-# loc("Total time left")
-# loc("Average time left")
-# loc("Minimum time left")
-# loc("Maximum time left")
-# loc("Summary of Created to Started")
-# loc("Total Created to Started")
-# loc("Average Created to Started")
-# loc("Minimum Created to Started")
-# loc("Maximum Created to Started")
-# loc("Summary of Created to Resolved")
-# loc("Total Created to Resolved")
-# loc("Average Created to Resolved")
-# loc("Minimum Created to Resolved")
-# loc("Maximum Created to Resolved")
-# loc("Summary of Created to LastUpdated")
-# loc("Total Created to LastUpdated")
-# loc("Average Created to LastUpdated")
-# loc("Minimum Created to LastUpdated")
-# loc("Maximum Created to LastUpdated")
-# loc("Summary of Starts to Started")
-# loc("Total Starts to Started")
-# loc("Average Starts to Started")
-# loc("Minimum Starts to Started")
-# loc("Maximum Starts to Started")
-# loc("Summary of Due to Resolved")
-# loc("Total Due to Resolved")
-# loc("Average Due to Resolved")
-# loc("Minimum Due to Resolved")
-# loc("Maximum Due to Resolved")
-# loc("Summary of Started to Resolved")
-# loc("Total Started to Resolved")
-# loc("Average Started to Resolved")
-# loc("Minimum Started to Resolved")
-# loc("Maximum Started to Resolved")
-
-our @STATISTICS = (
-    COUNT => ['Ticket count', 'Count', 'id'],
-);
-
-foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
-    my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
-    push @STATISTICS, (
-        "ALL($field)" => ["Summary of $friendly",   'TimeAll',     $field ],
-        "SUM($field)" => ["Total $friendly",   'Time', 'SUM', $field ],
-        "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
-        "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
-        "MAX($field)" => ["Maximum $friendly", 'Time', 'MAX', $field ],
-    );
-}
-
-
-foreach my $pair (
-    'Created to Started',
-    'Created to Resolved',
-    'Created to LastUpdated',
-    'Starts to Started',
-    'Due to Resolved',
-    'Started to Resolved',
-) {
-    my ($from, $to) = split / to /, $pair;
-    push @STATISTICS, (
-        "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ],
-        "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ],
-        "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
-        "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
-        "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],
-    );
-    push @GROUPINGS, $pair => 'Duration';
-
-    my %extra_info = ( business_time => 1 );
-    if ( keys %{RT->Config->Get('ServiceBusinessHours')} ) {
-        my $business_pair = "$pair(Business Hours)";
-        push @STATISTICS, (
-            "ALL($business_pair)" => ["Summary of $business_pair", 'DateTimeIntervalAll', $from, $to, \%extra_info ],
-            "SUM($business_pair)" => ["Total $business_pair", 'DateTimeInterval', 'SUM', $from, $to, \%extra_info ],
-            "AVG($business_pair)" => ["Average $business_pair", 'DateTimeInterval', 'AVG', $from, $to, \%extra_info ],
-            "MIN($business_pair)" => ["Minimum $business_pair", 'DateTimeInterval', 'MIN', $from, $to, \%extra_info ],
-            "MAX($business_pair)" => ["Maximum $business_pair", 'DateTimeInterval', 'MAX', $from, $to, \%extra_info ],
-        );
-        push @GROUPINGS, $business_pair => 'DurationInBusinessHours';
-    }
-}
-
-our %STATISTICS;
-
 our %STATISTICS_META = (
     Count => {
         Function => sub {
@@ -558,7 +410,7 @@ sub Groupings {
 
     my @fields;
 
-    my @tmp = @GROUPINGS;
+    my @tmp = $self->_Groupings();
     while ( my ($field, $type) = splice @tmp, 0, 2 ) {
         my $meta = $GROUPINGS_META{ $type } || {};
         unless ( $meta->{'SubFields'} ) {
@@ -587,7 +439,6 @@ sub IsValidGrouping {
 
     my ($key, $subkey) = split /(?<!CustomRole)\./, $args{'GroupBy'}, 2;
 
-    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
     my $type = $self->_GroupingType( $key );
     return 0 unless $type;
     return 1 unless $subkey;
@@ -607,7 +458,7 @@ sub IsValidGrouping {
 
 sub Statistics {
     my $self = shift;
-    return map { ref($_)? $_->[0] : $_ } @STATISTICS;
+    return map { ref($_)? $_->[0] : $_ } $self->_Statistics;
 }
 
 sub Label {
@@ -658,45 +509,6 @@ sub SetupGroupings {
         @_
     );
 
-    $self->FromSQL( $args{'Query'} ) if $args{'Query'};
-
-    # Apply ACL checks
-    $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
-
-    # See if our query is distinct
-    if (not $self->{'joins_are_distinct'} and $self->_isJoined) {
-        # If it isn't, we need to do this in two stages -- first, find
-        # the distinct matching tickets (with no group by), then search
-        # within the matching tickets grouped by what is wanted.
-        $self->Columns( 'id' );
-        if ( RT->Config->Get('UseSQLForACLChecks') ) {
-            my $query = $self->BuildSelectQuery( PreferBind => 0 );
-            $self->CleanSlate;
-            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 );
-        }
-        else {
-            # ACL is done in Next call
-            my @match = (0);
-            while ( my $row = $self->Next ) {
-                push @match, $row->id;
-            }
-
-            # Replace the query with one that matches precisely those
-            # tickets, with no joins.  We then mark it as having been ACL'd,
-            # since it was by dint of being in the search results above
-            $self->CleanSlate;
-            while ( @match > 1000 ) {
-                my @batch = splice( @match, 0, 1000 );
-                $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
-            }
-            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
-        }
-        $self->{'_sql_current_user_can_see_applied'} = 1
-    }
-
-
-    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
-
     my $i = 0;
 
     my @group_by = grep defined && length,
@@ -742,7 +554,7 @@ sub SetupGroupings {
         push @{ $res{'Groups'} }, $group_by->{'NAME'};
     }
 
-    %STATISTICS = @STATISTICS unless keys %STATISTICS;
+    my %statistics = $self->_Statistics;
 
     my @function = grep defined && length,
         ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
@@ -751,8 +563,8 @@ sub SetupGroupings {
         $e = {
             TYPE => 'statistic',
             KEY  => $e,
-            INFO => $STATISTICS{ $e },
-            META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
+            INFO => $statistics{ $e },
+            META => $STATISTICS_META{ $statistics{ $e }[1] },
             POSITION => $i++,
         };
         unless ( $e->{'INFO'} && $e->{'META'} ) {
@@ -790,334 +602,9 @@ sub SetupGroupings {
 
     $self->{'column_info'} = \%column_info;
 
-    if ($args{Query}
-        && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $column_info{$_} } @{ $res{Groups} } )
-            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ }
-                values %column_info )
-            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} }
-                values %column_info ) )
-       )
-    {
-        # Need to do the groupby/calculation at Perl level
-        $self->{_query} = $args{'Query'};
-    }
-    else {
-        delete $self->{_query};
-    }
-
     return %res;
 }
 
-=head2 _DoSearch
-
-Subclass _DoSearch from our parent so we can go through and add in empty 
-columns if it makes sense 
-
-=cut
-
-sub _DoSearch {
-    my $self = shift;
-
-    # When groupby/calculation can't be done at SQL level, do it at Perl level
-    if ( $self->{_query} ) {
-        my $tickets = RT::Tickets->new( $self->CurrentUser );
-        $tickets->FromSQL( $self->{_query} );
-        my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList;
-        my %info;
-        while ( my $ticket = $tickets->Next ) {
-            my @keys;
-            my $max = 1;
-            for my $group ( @groups ) {
-                my $value;
-
-                if ( $ticket->_Accessible($group->{KEY}, 'read' )) {
-                    if ( $group->{SUBKEY} ) {
-                        my $method = "$group->{KEY}Obj";
-                        if ( my $obj = $ticket->$method ) {
-                            if ( $group->{INFO} eq 'Date' ) {
-                                if ( $obj->Unix > 0 ) {
-                                    $value = $obj->Strftime( $GROUPINGS_META{Date}{StrftimeFormat}{ $group->{SUBKEY} },
-                                        Timezone => 'user' );
-                                }
-                                else {
-                                    $value = $self->loc('(no value)')
-                                }
-                            }
-                            else {
-                                $value = $obj->_Value($group->{SUBKEY});
-                            }
-                            $value //= $self->loc('(no value)');
-                        }
-                    }
-                    $value //= $ticket->_Value( $group->{KEY} ) // $self->loc('(no value)');
-                }
-                elsif ( $group->{INFO} eq 'Watcher' ) {
-                    my @values;
-                    if ( $ticket->can($group->{KEY}) ) {
-                        my $method = $group->{KEY};
-                        push @values, @{$ticket->$method->UserMembersObj->ItemsArrayRef};
-                    }
-                    elsif ( $group->{KEY} eq 'Watcher' ) {
-                        push @values, @{$ticket->$_->UserMembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/;
-                    }
-                    else {
-                        RT->Logger->error("Unsupported group by $group->{KEY}");
-                        next;
-                    }
-
-                    @values = map { $_->_Value( $group->{SUBKEY} || 'Name' ) } @values;
-                    @values = $self->loc('(no value)') unless @values;
-                    $value = \@values;
-                }
-                elsif ( $group->{INFO} eq 'CustomField' ) {
-                    my ($id) = $group->{SUBKEY} =~ /{(\d+)}/;
-                    my $values = $ticket->CustomFieldValues($id);
-                    if ( $values->Count ) {
-                        $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ];
-                    }
-                    else {
-                        $value = $self->loc('(no value)');
-                    }
-                }
-                elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) {
-                    my $business_time = $1;
-
-                    if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) {
-                        my $start        = $1;
-                        my $end          = $2;
-                        my $start_method = $start . 'Obj';
-                        my $end_method   = $end . 'Obj';
-                        if ( $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0 ) {
-                            my $seconds;
-
-                            if ($business_time) {
-                                $seconds = $ticket->CustomDateRange(
-                                    '',
-                                    {   value         => "$end - $start",
-                                        business_time => 1,
-                                        format        => sub { $_[0] },
-                                    }
-                                );
-                            }
-                            else {
-                                $seconds = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
-                            }
-
-                            if ( $group->{SUBKEY} eq 'Default' ) {
-                                $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                    $seconds,
-                                    Show    => $group->{META}{Show},
-                                    Short   => $group->{META}{Short},
-                                    MaxUnit => $business_time ? 'hour' : 'year',
-                                );
-                            }
-                            else {
-                                $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                    $seconds,
-                                    Show    => $group->{META}{Show} // 3,
-                                    Short   => $group->{META}{Short} // 1,
-                                    MaxUnit => lc $group->{SUBKEY},
-                                    MinUnit => lc $group->{SUBKEY},
-                                    Unit    => lc $group->{SUBKEY},
-                                );
-                            }
-                        }
-                    }
-                    else {
-                        my %ranges = RT::Ticket->CustomDateRanges;
-                        if ( my $spec = $ranges{$group->{FIELD}} ) {
-                            if ( $group->{SUBKEY} eq 'Default' ) {
-                                $value = $ticket->CustomDateRange( $group->{FIELD}, $spec );
-                            }
-                            else {
-                                my $seconds = $ticket->CustomDateRange( $group->{FIELD},
-                                    { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } );
-
-                                if ( defined $seconds ) {
-                                    $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                        $seconds,
-                                        Show    => $group->{META}{Show} // 3,
-                                        Short   => $group->{META}{Short} // 1,
-                                        MaxUnit => lc $group->{SUBKEY},
-                                        MinUnit => lc $group->{SUBKEY},
-                                        Unit    => lc $group->{SUBKEY},
-                                    );
-                                }
-                            }
-                        }
-                    }
-
-                    $value //= $self->loc('(no value)');
-                }
-                else {
-                    RT->Logger->error("Unsupported group by $group->{KEY}");
-                    next;
-                }
-                push @keys, $value;
-            }
-
-            # @keys could contain arrayrefs, so we need to expand it.
-            # e.g. "open", [ "root", "foo" ], "General" )
-            # will be expanded to:
-            #   "open", "root", "General"
-            #   "open", "foo", "General"
-
-            my @all_keys;
-            for my $key (@keys) {
-                if ( ref $key eq 'ARRAY' ) {
-                    if (@all_keys) {
-                        my @new_all_keys;
-                        for my $keys ( @all_keys ) {
-                            push @new_all_keys, [ @$keys, $_ ] for @$key;
-                        }
-                        @all_keys = @new_all_keys;
-                    }
-                    else {
-                        push @all_keys, [$_] for @$key;
-                    }
-                }
-                else {
-                    if (@all_keys) {
-                        @all_keys = map { [ @$_, $key ] } @all_keys;
-                    }
-                    else {
-                        push @all_keys, [$key];
-                    }
-                }
-            }
-
-            my @fields = grep { $_->{TYPE} eq 'statistic' }
-                map { $self->ColumnInfo($_) } $self->ColumnsList;
-
-            while ( my $field = shift @fields ) {
-                for my $keys (@all_keys) {
-                    my $key = join ';;;', @$keys;
-                    if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) {
-                        $info{$key}{ $field->{NAME} }++;
-                    }
-                    elsif ( $field->{NAME} =~ /^postfunction/ ) {
-                        if ( $field->{MAP} ) {
-                            my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/;
-                            for my $item ( values %{ $field->{MAP} } ) {
-                                push @fields,
-                                    {
-                                    NAME  => $item->{NAME},
-                                    FIELD => $item->{FIELD},
-                                    INFO  => [
-                                        '', $meta_type,
-                                        $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '',
-                                        @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ],
-                                    ],
-                                    };
-                            }
-                        }
-                    }
-                    elsif ( $field->{INFO}[1] eq 'Time' ) {
-                        if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) {
-                            my $method = $1;
-                            my $type   = $field->{INFO}[2];
-                            my $name   = lc $field->{NAME};
-
-                            $info{$key}{$name}
-                                = $self->_CalculateTime( $type, $ticket->$method * 60, $info{$key}{$name} ) || 0;
-                        }
-                        else {
-                            RT->Logger->error("Unsupported field $field->{NAME}");
-                        }
-                    }
-                    elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) {
-                        my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} };
-                        my $name = lc $field->{NAME};
-                        $info{$key}{$name} ||= 0;
-
-                        my $start_method = $start . 'Obj';
-                        my $end_method   = $end . 'Obj';
-                        next unless $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0;
-
-                        my $value;
-                        if ($extra_info->{business_time}) {
-                            $value = $ticket->CustomDateRange(
-                                '',
-                                {   value         => "$end - $start",
-                                    business_time => 1,
-                                    format        => sub { return $_[0] },
-                                }
-                            );
-                        }
-                        else {
-                            $value = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
-                        }
-
-                        $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
-                    }
-                    elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) {
-                        my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} };
-                        my $name = lc $field->{NAME};
-                        $info{$key}{$name} ||= 0;
-
-                        my $value;
-                        my %ranges = RT::Ticket->CustomDateRanges;
-                        if ( my $spec = $ranges{$range_name} ) {
-                            $value = $ticket->CustomDateRange(
-                                $range_name,
-                                {
-                                    ref $spec eq 'HASH' ? %$spec : ( value => $spec ),
-                                    format => sub { $_[0] },
-                                }
-                            );
-                        }
-                        $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
-                    }
-                    else {
-                        RT->Logger->error("Unsupported field $field->{INFO}[1]");
-                    }
-                }
-            }
-
-            for my $keys (@all_keys) {
-                my $key = join ';;;', @$keys;
-                push @{ $info{$key}{ids} }, $ticket->id;
-            }
-        }
-
-        # Make generated results real SB results
-        for my $key ( keys %info ) {
-            my @keys = split /;;;/, $key;
-            my $row;
-            for my $group ( @groups ) {
-                $row->{lc $group->{NAME}} = shift @keys;
-            }
-            for my $field ( keys %{ $info{$key} } ) {
-                my $value = $info{$key}{$field};
-                if ( ref $value eq 'HASH' && $value->{calculate} ) {
-                    $row->{$field} = $value->{calculate}->($value);
-                }
-                else {
-                    $row->{$field} = $info{$key}{$field};
-                }
-            }
-            my $item = $self->NewItem();
-            $item->LoadFromHash($row);
-            $self->AddRecord($item);
-        }
-        $self->{must_redo_search} = 0;
-        $self->{is_limited} = 1;
-        $self->PostProcessRecords;
-
-        return;
-    }
-
-    $self->SUPER::_DoSearch( @_ );
-    if ( $self->{'must_redo_search'} ) {
-        $RT::Logger->crit(
-"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
-        );
-    }
-    else {
-        $self->PostProcessRecords;
-    }
-}
-
 =head2 _FieldToFunction FIELD
 
 Returns a tuple of the field or a database function to allow grouping on that 
@@ -1142,28 +629,6 @@ sub _FieldToFunction {
     return $code->( $self, %args );
 }
 
-
-# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we 
-# don't want.
-sub Next {
-    my $self = shift;
-    $self->RT::SearchBuilder::Next(@_);
-
-}
-
-sub NewItem {
-    my $self = shift;
-    my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
-    $res->{'report'} = $self;
-    weaken $res->{'report'};
-    return $res;
-}
-
-# This is necessary since normally NewItem (above) is used to intuit the
-# correct class.  However, since we're abusing a subclass, it's incorrect.
-sub _RoleGroupClass { "RT::Ticket" }
-sub _SingularClass { "RT::Report::Tickets::Entry" }
-
 sub SortEntries {
     my $self = shift;
 
@@ -1670,36 +1135,31 @@ sub _CalculateTime {
     return $current;
 }
 
-sub new {
-    my $self = shift;
-    $self->_SetupCustomDateRanges;
-    return $self->SUPER::new(@_);
-}
-
-
 sub _SetupCustomDateRanges {
     my $self = shift;
     my %names;
+    my @groupings = $self->_Groupings;
+    my @statistics = $self->_Statistics;
 
     # Remove old custom date range groupings
-    for my $field ( grep {ref} @STATISTICS ) {
+    for my $field ( grep {ref} $self->_Statistics ) {
         if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) {
             $names{ $field->[2] } = 1;
         }
     }
 
     my ( @new_groupings, @new_statistics );
-    while (@GROUPINGS) {
-        my $name = shift @GROUPINGS;
-        my $type = shift @GROUPINGS;
+    while (@groupings) {
+        my $name = shift @groupings;
+        my $type = shift @groupings;
         if ( !$names{$name} ) {
             push @new_groupings, $name, $type;
         }
     }
 
-    while (@STATISTICS) {
-        my $key    = shift @STATISTICS;
-        my $info   = shift @STATISTICS;
+    while (@statistics) {
+        my $key    = shift @statistics;
+        my $info   = shift @statistics;
         my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/;
         unless ( $name && $names{$name} ) {
             push @new_statistics, $key, $info;
@@ -1707,7 +1167,7 @@ sub _SetupCustomDateRanges {
     }
 
     # Add new ones
-    my %ranges = RT::Ticket->CustomDateRanges;
+    my %ranges = $self->_SingularClass->CustomDateRanges;
     for my $name ( sort keys %ranges ) {
         my %extra_info;
         my $spec = $ranges{$name};
@@ -1726,9 +1186,8 @@ sub _SetupCustomDateRanges {
             );
     }
 
-    @GROUPINGS  = @new_groupings;
-    @STATISTICS = @new_statistics;
-    %GROUPINGS  = %STATISTICS = ();
+    $self->_Groupings( @new_groupings );
+    $self->_Statistics( @new_statistics );
 
     return 1;
 }
@@ -1738,13 +1197,77 @@ sub _GroupingType {
     my $key  = shift or return;
     # keys for custom roles are like "CustomRole.{1}"
     $key = 'CustomRole' if $key =~ /^CustomRole/;
-    return $GROUPINGS{$key};
+    return { $self->_Groupings }->{$key};
+}
+
+sub _GroupingsMeta { return \%GROUPINGS_META };
+sub _StatisticsMeta { return \%STATISTICS_META };
+
+# Return the corresponding @GROUPINGS in subclass
+sub _Groupings {
+    my $self  = shift;
+    my $class = ref($self) || $self;
+    no strict 'refs';
+
+    if (@_) {
+        @{ $class . '::GROUPINGS' } = @_;
+    }
+    return @{ $class . '::GROUPINGS' };
+}
+
+# Return the corresponding @STATISTICS in subclass
+sub _Statistics {
+    my $self  = shift;
+    my $class = ref($self) || $self;
+    no strict 'refs';
+
+    if (@_) {
+        @{ $class . '::STATISTICS' } = @_;
+    }
+    return @{ $class . '::STATISTICS' };
 }
 
+=head2 DefaultGroupBy
+
+By default, it's the first item in @GROUPINGS.
+
+=cut
+
 sub DefaultGroupBy {
-    return 'Status';
+    my $self  = shift;
+    my $class = ref($self) || $self;
+    no strict 'refs';
+    ${ $class . '::GROUPINGS' }[0];
+}
+
+# The following methods are more collection related
+
+sub _PostSearch {
+    my $self = shift;
+    if ( $self->{'must_redo_search'} ) {
+        $RT::Logger->crit(
+"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
+        );
+    }
+    else {
+        $self->PostProcessRecords;
+    }
+}
+
+sub NewItem {
+    my $self = shift;
+    my $res = $self->_SingularClass->new($self->CurrentUser);
+    $res->{'report'} = $self;
+    weaken $res->{'report'};
+    return $res;
 }
 
+# This is necessary since normally NewItem (above) is used to intuit the
+# correct class.  However, since we're abusing a subclass, it's incorrect.
+sub _RoleGroupClass { die "should be subclassed" }
+sub _SingularClass { die "should be subclassed" }
+
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Entry.pm
similarity index 96%
copy from lib/RT/Report/Tickets/Entry.pm
copy to lib/RT/Report/Entry.pm
index 2f90254fbd..76af268b92 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Entry.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::Report::Tickets::Entry;
+package RT::Report::Entry;
 
 use warnings;
 use strict;
@@ -96,12 +96,12 @@ sub RawValue {
     return (shift)->__Value( @_ );
 }
 
-sub ObjectType {
-    return 'RT::Ticket';
-}
+# Used in RT::SearchBuilder::JoinTransactions and CustomFieldLookupType
+sub ObjectType { die "should be subclassed" }
 
 sub CustomFieldLookupType {
-    RT::Ticket->CustomFieldLookupType
+    my $self = shift;
+    return $self->ObjectType->CustomFieldLookupType;
 }
 
 sub Query {
diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 4aa71696f4..29a57a071c 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -48,13 +48,12 @@
 
 package RT::Report::Tickets;
 
-use base qw/RT::Tickets/;
+use base qw/RT::Report RT::Tickets/;
 use RT::Report::Tickets::Entry;
 
 use strict;
 use warnings;
 use 5.010;
-use Scalar::Util qw(weaken);
 
 __PACKAGE__->RegisterCustomFieldJoin(@$_) for
     [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
@@ -100,261 +99,6 @@ our @GROUPINGS = (
 
     SLA           => 'Enum',            #loc_left_pair
 );
-our %GROUPINGS;
-
-our %GROUPINGS_META = (
-    Queue => {
-        Display => sub {
-            my $self = shift;
-            my %args = (@_);
-
-            my $queue = RT::Queue->new( $self->CurrentUser );
-            $queue->Load( $args{'VALUE'} );
-            return $queue->Name;
-        },
-        Localize => 1,
-        Distinct => 1,
-    },
-    Priority => {
-        Sort => 'numeric raw',
-        Distinct => 1,
-    },
-    User => {
-        SubFields => [grep RT::User->_Accessible($_, "public"), qw(
-            Name RealName NickName
-            EmailAddress
-            Organization
-            Lang City Country Timezone
-        )],
-        Function => 'GenerateUserFunction',
-        Distinct => 1,
-    },
-    Watcher => {
-        SubFields => sub {
-            my $self = shift;
-            my $args = shift;
-
-            my %fields = (
-                user => [ grep RT::User->_Accessible( $_, "public" ),
-                    qw( Name RealName NickName EmailAddress Organization Lang City Country Timezone) ],
-                principal => [ grep RT::User->_Accessible( $_, "public" ), qw( Name ) ],
-            );
-
-            my @res;
-            if ( $args->{key} =~ /^CustomRole/ ) {
-                my $queues = $args->{'Queues'};
-                if ( !$queues && $args->{'Query'} ) {
-                    require RT::Interface::Web::QueryBuilder::Tree;
-                    my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-                    $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
-                    $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
-                }
-                return () unless $queues;
-
-                my $crs = RT::CustomRoles->new( $self->CurrentUser );
-                for my $id ( keys %$queues ) {
-                    my $queue = RT::Queue->new( $self->CurrentUser );
-                    $queue->Load($id);
-                    next unless $queue->id;
-
-                    $crs->LimitToObjectId( $queue->id );
-                }
-                while ( my $cr = $crs->Next ) {
-                    for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) {
-                        push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field";
-                    }
-                }
-            }
-            else {
-                for my $field ( @{ $fields{principal} } ) {
-                    push @res, [ $args->{key}, $field ], "$args->{key}.$field";
-                }
-            }
-            return @res;
-        },
-        Function => 'GenerateWatcherFunction',
-        Label    => sub {
-            my $self = shift;
-            my %args = (@_);
-
-            my $key;
-            if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) {
-                my $id = $1;
-                my $cr = RT::CustomRole->new( $self->CurrentUser );
-                $cr->Load($id);
-                $key = $cr->Name;
-            }
-            else {
-                $key = $args{KEY};
-            }
-            return join ' ', $key, $args{SUBKEY};
-        },
-        Display => sub {
-            my $self = shift;
-            my %args = (@_);
-            if ( $args{FIELD} eq 'id' ) {
-                my $princ = RT::Principal->new( $self->CurrentUser );
-                $princ->Load( $args{'VALUE'} ) if $args{'VALUE'};
-                return $self->loc('(no value)') unless $princ->Id;
-                return $princ->IsGroup ? $self->loc( 'Group: [_1]', $princ->Object->Name ) : $princ->Object->Name;
-            }
-            else {
-                return $args{VALUE};
-            }
-        },
-        Distinct => sub {
-            my $self = shift;
-            my %args = @_;
-            if ( $args{KEY} =~ /^CustomRole\.\{(\d+)\}/ ) {
-                my $id = $1;
-                my $obj = RT::CustomRole->new( RT->SystemUser );
-                $obj->Load( $id );
-                if ( $obj->MaxValues == 1 ) {
-                    return 1;
-                }
-                else {
-                    return 0;
-                }
-            }
-            return 0;
-        },
-    },
-    Date => {
-        SubFields => [qw(
-            Time
-            Hourly Hour
-            Date Daily
-            DayOfWeek Day DayOfMonth DayOfYear
-            Month Monthly
-            Year Annually
-            WeekOfYear
-        )],  # loc_qw
-        StrftimeFormat => {
-            Time       => '%T',
-            Hourly     => '%Y-%m-%d %H',
-            Hour       => '%H',
-            Date       => '%F',
-            Daily      => '%F',
-            DayOfWeek  => '%w',
-            Day        => '%F',
-            DayOfMonth => '%d',
-            DayOfYear  => '%j',
-            Month      => '%m',
-            Monthly    => '%Y-%m',
-            Year       => '%Y',
-            Annually   => '%Y',
-            WeekOfYear => '%W',
-        },
-        Function => 'GenerateDateFunction',
-        Display => sub {
-            my $self = shift;
-            my %args = (@_);
-
-            my $raw = $args{'VALUE'};
-            return $raw unless defined $raw;
-
-            if ( $args{'SUBKEY'} eq 'DayOfWeek' ) {
-                return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]);
-            }
-            elsif ( $args{'SUBKEY'} eq 'Month' ) {
-                return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]);
-            }
-            return $raw;
-        },
-        Sort => 'raw',
-        Distinct => 1,
-    },
-    CustomField => {
-        SubFields => sub {
-            my $self = shift;
-            my $args = shift;
-
-
-            my $queues = $args->{'Queues'};
-            if ( !$queues && $args->{'Query'} ) {
-                require RT::Interface::Web::QueryBuilder::Tree;
-                my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-                $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
-                $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
-            }
-            return () unless $queues;
-
-            my @res;
-
-            my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
-            foreach my $id (keys %$queues) {
-                my $queue = RT::Queue->new( $self->CurrentUser );
-                $queue->Load($id);
-                next unless $queue->id;
-                $CustomFields->SetContextObject( $queue ) if keys %$queues == 1;
-                $CustomFields->LimitToQueue($queue->id);
-            }
-            $CustomFields->LimitToGlobal;
-            while ( my $CustomField = $CustomFields->Next ) {
-                push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
-            }
-            return @res;
-        },
-        Function => 'GenerateCustomFieldFunction',
-        Label => sub {
-            my $self = shift;
-            my %args = (@_);
-
-            my ($cf) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
-            if ( $cf =~ /^\d+$/ ) {
-
-                # When we render label in charts, the cf could surely be
-                # seen by current user(SubFields above checks rights), but
-                # we can't use current user to load cf here because the
-                # right might be granted at queue level and it's not
-                # straightforward to add a related queue as context object
-                # here. That's why we use RT->SystemUser here instead.
-
-                my $obj = RT::CustomField->new( RT->SystemUser );
-                $obj->Load( $cf );
-                $cf = $obj->Name;
-            }
-
-            return 'Custom field [_1]', $cf;
-        },
-        Distinct => sub {
-            my $self = shift;
-            my %args = @_;
-            if ( $args{SUBKEY} =~ /\{(\d+)\}/ ) {
-                my $id = $1;
-                my $obj = RT::CustomField->new( RT->SystemUser );
-                $obj->Load( $id );
-                if ( $obj->MaxValues == 1 ) {
-                    return 1;
-                }
-                else {
-                    return 0;
-                }
-            }
-            return 0;
-        },
-    },
-    Enum => {
-        Localize => 1,
-        Distinct => 1,
-    },
-    Duration => {
-        SubFields => [ qw/Default Hour Day Week Month Year/ ],
-        Localize => 1,
-        Short    => 0,
-        Show     => 1,
-        Sort     => 'duration',
-        Distinct => 1,
-    },
-    DurationInBusinessHours => {
-        SubFields => [ qw/Default Hour/ ],
-        Localize => 1,
-        Short    => 0,
-        Show     => 1,
-        Sort     => 'duration',
-        Distinct => 1,
-    },
-);
 
 # loc'able strings below generated with (s/loq/loc/):
 #   perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
@@ -454,201 +198,6 @@ foreach my $pair (
     }
 }
 
-our %STATISTICS;
-
-our %STATISTICS_META = (
-    Count => {
-        Function => sub {
-            my $self = shift;
-            my $field = shift || 'id';
-
-            return (
-                FUNCTION => 'COUNT',
-                FIELD    => 'id'
-            );
-        },
-    },
-    Simple => {
-        Function => sub {
-            my $self = shift;
-            my ($function, $field) = @_;
-            return (FUNCTION => $function, FIELD => $field);
-        },
-    },
-    Time => {
-        Function => sub {
-            my $self = shift;
-            my ($function, $field) = @_;
-            return (FUNCTION => "$function(?)*60", FIELD => $field);
-        },
-        Display => 'DurationAsString',
-    },
-    TimeAll => {
-        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
-        Function => sub {
-            my $self = shift;
-            my $field = shift;
-            return (
-                Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field },
-                Average => { FUNCTION => "AVG(?)*60", FIELD => $field },
-                Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field },
-                Total   => { FUNCTION => "SUM(?)*60", FIELD => $field },
-            );
-        },
-        Display => 'DurationAsString',
-    },
-    DateTimeInterval => {
-        Function => sub {
-            my $self = shift;
-            my ($function, $from, $to) = @_;
-
-            my $interval = $self->_Handle->DateTimeIntervalFunction(
-                From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
-                To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
-            );
-
-            return (FUNCTION => "$function($interval)");
-        },
-        Display => 'DurationAsString',
-    },
-    DateTimeIntervalAll => {
-        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
-        Function => sub {
-            my $self = shift;
-            my ($from, $to) = @_;
-
-            my $interval = $self->_Handle->DateTimeIntervalFunction(
-                From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
-                To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
-            );
-
-            return (
-                Minimum => { FUNCTION => "MIN($interval)" },
-                Average => { FUNCTION => "AVG($interval)" },
-                Maximum => { FUNCTION => "MAX($interval)" },
-                Total   => { FUNCTION => "SUM($interval)" },
-            );
-        },
-        Display => 'DurationAsString',
-    },
-    CustomDateRange => {
-        Display => 'DurationAsString',
-        Function => sub {}, # Placeholder to use the same DateTimeInterval handling
-    },
-    CustomDateRangeAll => {
-        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
-        Function => sub {
-            my $self = shift;
-
-            # To use the same DateTimeIntervalAll handling, not real SQL
-            return (
-                Minimum => { FUNCTION => "MIN" },
-                Average => { FUNCTION => "AVG" },
-                Maximum => { FUNCTION => "MAX" },
-                Total   => { FUNCTION => "SUM" },
-            );
-        },
-        Display => 'DurationAsString',
-    },
-);
-
-sub Groupings {
-    my $self = shift;
-    my %args = (@_);
-
-    my @fields;
-
-    my @tmp = @GROUPINGS;
-    while ( my ($field, $type) = splice @tmp, 0, 2 ) {
-        my $meta = $GROUPINGS_META{ $type } || {};
-        unless ( $meta->{'SubFields'} ) {
-            push @fields, [$field, $field], $field;
-        }
-        elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
-            push @fields, map { ([$field, $_], "$field.$_") } @{ $meta->{'SubFields'} };
-        }
-        elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) {
-            push @fields, $code->( $self, { %args, key => $field } );
-        }
-        else {
-            $RT::Logger->error(
-                "$type has unsupported SubFields."
-                ." Not an array, a method name or a code reference"
-            );
-        }
-    }
-    return @fields;
-}
-
-sub IsValidGrouping {
-    my $self = shift;
-    my %args = (@_);
-    return 0 unless $args{'GroupBy'};
-
-    my ($key, $subkey) = split /(?<!CustomRole)\./, $args{'GroupBy'}, 2;
-
-    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
-    my $type = $self->_GroupingType( $key );
-    return 0 unless $type;
-    return 1 unless $subkey;
-
-    my $meta = $GROUPINGS_META{ $type } || {};
-    unless ( $meta->{'SubFields'} ) {
-        return 0;
-    }
-    elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
-        return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} };
-    }
-    elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'}, 'silent' ) ) {
-        return 1 if grep $_ eq "$key.$subkey", $code->( $self, { %args, key => $key } );
-    }
-    return 0;
-}
-
-sub Statistics {
-    my $self = shift;
-    return map { ref($_)? $_->[0] : $_ } @STATISTICS;
-}
-
-sub Label {
-    my $self = shift;
-    my $column = shift;
-
-    my $info = $self->ColumnInfo( $column );
-    unless ( $info ) {
-        $RT::Logger->error("Unknown column '$column'");
-        return $self->CurrentUser->loc('(Incorrect data)');
-    }
-
-    if ( $info->{'META'}{'Label'} ) {
-        my $code = $self->FindImplementationCode( $info->{'META'}{'Label'} );
-        return $self->CurrentUser->loc( $code->( $self, %$info ) )
-            if $code;
-    }
-
-    my $res = '';
-    if ( $info->{'TYPE'} eq 'statistic' ) {
-        $res = $info->{'INFO'}[0];
-    }
-    else {
-        $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'};
-    }
-    return $self->CurrentUser->loc( $res );
-}
-
-sub ColumnInfo {
-    my $self = shift;
-    my $column = shift;
-
-    return $self->{'column_info'}{$column};
-}
-
-sub ColumnsList {
-    my $self = shift;
-    return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} }
-        keys %{ $self->{'column_info'} || {} };
-}
-
 sub SetupGroupings {
     my $self = shift;
     my %args = (
@@ -694,108 +243,14 @@ sub SetupGroupings {
         $self->{'_sql_current_user_can_see_applied'} = 1
     }
 
-
-    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
-
-    my $i = 0;
-
-    my @group_by = grep defined && length,
-        ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
-    @group_by = $self->DefaultGroupBy unless @group_by;
-
-    my $distinct_results = 1;
-    foreach my $e ( splice @group_by ) {
-        unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) {
-            RT->Logger->error("'$e' is not a valid grouping for reports; skipping");
-            next;
-        }
-        my ($key, $subkey) = split /(?<!CustomRole)\./, $e, 2;
-        $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
-        $e->{'TYPE'} = 'grouping';
-        $e->{'INFO'} = $self->_GroupingType($key);
-        $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
-        $e->{'POSITION'} = $i++;
-        if ( my $distinct = $e->{'META'}{Distinct} ) {
-            if ( ref($distinct) eq 'CODE' ) {
-                $distinct_results = 0 unless $distinct->( $self, KEY => $key, SUBKEY => $subkey );
-            }
-        }
-        else {
-            $distinct_results = 0;
-        }
-        push @group_by, $e;
-    }
-    $self->{_distinct_results} = $distinct_results;
-
-    $self->GroupBy( map { {
-        ALIAS    => $_->{'ALIAS'},
-        FIELD    => $_->{'FIELD'},
-        FUNCTION => $_->{'FUNCTION'},
-    } } @group_by );
-
-    my %res = (Groups => [], Functions => []);
-    my %column_info;
-
-    foreach my $group_by ( @group_by ) {
-        $group_by->{'NAME'} = $self->Column( %$group_by );
-        $column_info{ $group_by->{'NAME'} } = $group_by;
-        push @{ $res{'Groups'} }, $group_by->{'NAME'};
-    }
-
-    %STATISTICS = @STATISTICS unless keys %STATISTICS;
-
-    my @function = grep defined && length,
-        ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
-    push @function, 'COUNT' unless @function;
-    foreach my $e ( @function ) {
-        $e = {
-            TYPE => 'statistic',
-            KEY  => $e,
-            INFO => $STATISTICS{ $e },
-            META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
-            POSITION => $i++,
-        };
-        unless ( $e->{'INFO'} && $e->{'META'} ) {
-            $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report");
-            $e->{'FUNCTION'} = 'NULL';
-            $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
-        }
-        elsif ( $e->{'META'}{'Function'} ) {
-            my $code = $self->FindImplementationCode( $e->{'META'}{'Function'} );
-            unless ( $code ) {
-                $e->{'FUNCTION'} = 'NULL';
-                $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
-            }
-            elsif ( $e->{'META'}{'SubValues'} ) {
-                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
-                $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
-                while ( my ($k, $v) = each %tmp ) {
-                    $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v );
-                    @{ $e->{'MAP'}{ $k } }{'FUNCTION', 'ALIAS', 'FIELD'} =
-                        @{ $v }{'FUNCTION', 'ALIAS', 'FIELD'};
-                }
-            }
-            else {
-                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
-                $e->{'NAME'} = $self->Column( %tmp );
-                @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'};
-            }
-        }
-        elsif ( $e->{'META'}{'Calculate'} ) {
-            $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
-        }
-        push @{ $res{'Functions'} }, $e->{'NAME'};
-        $column_info{ $e->{'NAME'} } = $e;
-    }
-
-    $self->{'column_info'} = \%column_info;
+    my %res = $self->SUPER::SetupGroupings(%args);
 
     if ($args{Query}
-        && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $column_info{$_} } @{ $res{Groups} } )
+        && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } )
             || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ }
-                values %column_info )
+                values %{ $self->{column_info} } )
             || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} }
-                values %column_info ) )
+                values %{ $self->{column_info} } ) )
        )
     {
         # Need to do the groupby/calculation at Perl level
@@ -808,13 +263,6 @@ sub SetupGroupings {
     return %res;
 }
 
-=head2 _DoSearch
-
-Subclass _DoSearch from our parent so we can go through and add in empty 
-columns if it makes sense 
-
-=cut
-
 sub _DoSearch {
     my $self = shift;
 
@@ -836,7 +284,7 @@ sub _DoSearch {
                         if ( my $obj = $ticket->$method ) {
                             if ( $group->{INFO} eq 'Date' ) {
                                 if ( $obj->Unix > 0 ) {
-                                    $value = $obj->Strftime( $GROUPINGS_META{Date}{StrftimeFormat}{ $group->{SUBKEY} },
+                                    $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} },
                                         Timezone => 'user' );
                                 }
                                 else {
@@ -1108,643 +556,27 @@ sub _DoSearch {
     }
 
     $self->SUPER::_DoSearch( @_ );
-    if ( $self->{'must_redo_search'} ) {
-        $RT::Logger->crit(
-"_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
-        );
-    }
-    else {
-        $self->PostProcessRecords;
-    }
+    $self->_PostSearch();
 }
 
-=head2 _FieldToFunction FIELD
-
-Returns a tuple of the field or a database function to allow grouping on that 
-field.
-
-=cut
-
-sub _FieldToFunction {
+sub new {
     my $self = shift;
-    my %args = (@_);
-
-    $args{'FIELD'} ||= $args{'KEY'};
-
-    my $meta = $GROUPINGS_META{ $self->_GroupingType( $args{'KEY'} ) };
-    return ('FUNCTION' => 'NULL') unless $meta;
-
-    return %args unless $meta->{'Function'};
-
-    my $code = $self->FindImplementationCode( $meta->{'Function'} );
-    return ('FUNCTION' => 'NULL') unless $code;
-
-    return $code->( $self, %args );
+    $self->_SetupCustomDateRanges;
+    return $self->SUPER::new(@_);
 }
 
-
-# Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we 
+# Gotta skip over RT::Ticket->Next, since it does all sorts of crazy magic we 
 # don't want.
 sub Next {
     my $self = shift;
     $self->RT::SearchBuilder::Next(@_);
-
 }
 
-sub NewItem {
-    my $self = shift;
-    my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
-    $res->{'report'} = $self;
-    weaken $res->{'report'};
-    return $res;
-}
-
-# This is necessary since normally NewItem (above) is used to intuit the
+# This is necessary since normally NewItem is used to intuit the
 # correct class.  However, since we're abusing a subclass, it's incorrect.
 sub _RoleGroupClass { "RT::Ticket" }
 sub _SingularClass { "RT::Report::Tickets::Entry" }
 
-sub SortEntries {
-    my $self = shift;
-
-    $self->_DoSearch if $self->{'must_redo_search'};
-    return unless $self->{'items'} && @{ $self->{'items'} };
-
-    my @groups =
-        grep $_->{'TYPE'} eq 'grouping',
-        map $self->ColumnInfo($_),
-        $self->ColumnsList;
-    return unless @groups;
-
-    my @SORT_OPS;
-    my $by_multiple = sub ($$) {
-        for my $f ( @SORT_OPS ) {
-            my $r = $f->($_[0], $_[1]);
-            return $r if $r;
-        }
-    };
-    my @data = map [$_], @{ $self->{'items'} };
-
-    for ( my $i = 0; $i < @groups; $i++ ) {
-        my $group_by = $groups[$i];
-        my $idx = $i+1;
-
-        my $order = $group_by->{'META'}{Sort} || 'label';
-        my $method = $order =~ /label$/ ? 'LabelValue' : 'RawValue';
-
-        unless ($order =~ /^numeric/) {
-            # Traverse the values being used for labels.
-            # If they all look like numbers or undef, flag for a numeric sort.
-            my $looks_like_number = 1;
-            foreach my $item (@data){
-                my $label = $item->[0]->$method($group_by->{'NAME'});
-
-                $looks_like_number = 0
-                    unless (not defined $label)
-                    or Scalar::Util::looks_like_number( $label );
-            }
-            $order = "numeric $order" if $looks_like_number;
-        }
-
-        if ( $order eq 'label' ) {
-            push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
-            $method = 'LabelValue';
-        }
-        elsif ( $order eq 'numeric label' ) {
-            my $nv = $self->loc("(no value)");
-            # Sort the (no value) elements first, by comparing for them
-            # first, and falling back to a numeric sort on all other
-            # values.
-            push @SORT_OPS, sub {
-                (($_[0][$idx] ne $nv) <=> ($_[1][$idx] ne $nv))
-             || ( $_[0][$idx]         <=>  $_[1][$idx]        ) };
-            $method = 'LabelValue';
-        }
-        elsif ( $order eq 'raw' ) {
-            push @SORT_OPS, sub { ($_[0][$idx]//'') cmp ($_[1][$idx]//'') };
-            $method = 'RawValue';
-        }
-        elsif ( $order eq 'numeric raw' ) {
-            push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
-            $method = 'RawValue';
-        }
-        elsif ( $order eq 'duration' ) {
-            push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
-            $method = 'DurationValue';
-        } else {
-            $RT::Logger->error("Unknown sorting function '$order'");
-            next;
-        }
-        $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data;
-    }
-    $self->{'items'} = [
-        map $_->[0],
-        sort $by_multiple @data
-    ];
-}
-
-sub PostProcessRecords {
-    my $self = shift;
-
-    my $info = $self->{'column_info'};
-    foreach my $column ( values %$info ) {
-        next unless $column->{'TYPE'} eq 'statistic';
-        if ( $column->{'META'}{'Calculate'} ) {
-            $self->CalculatePostFunction( $column );
-        }
-        elsif ( $column->{'META'}{'SubValues'} ) {
-            $self->MapSubValues( $column );
-        }
-    }
-}
-
-sub CalculatePostFunction {
-    my $self = shift;
-    my $info = shift;
-
-    my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} );
-    unless ( $code ) {
-        # TODO: fill in undefs
-        return;
-    }
-
-    my $column = $info->{'NAME'};
-
-    my $base_query = $self->Query;
-    foreach my $item ( @{ $self->{'items'} } ) {
-        $item->{'values'}{ lc $column } = $code->(
-            $self,
-            Query => join(
-                ' AND ', map "($_)", grep defined && length, $base_query, $item->Query,
-            ),
-        );
-        $item->{'fetched'}{ lc $column } = 1;
-    }
-}
-
-sub MapSubValues {
-    my $self = shift;
-    my $info = shift;
-
-    my $to = $info->{'NAME'};
-    my $map = $info->{'MAP'};
-
-    foreach my $item ( @{ $self->{'items'} } ) {
-        my $dst = $item->{'values'}{ lc $to } = { };
-        while (my ($k, $v) = each %{ $map } ) {
-            $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} };
-            # This mirrors the logic in RT::Record::__Value When that
-            # ceases tp use the UTF-8 flag as a character/byte
-            # distinction from the database, this can as well.
-            utf8::decode( $dst->{ $k } )
-                if defined $dst->{ $k }
-               and not utf8::is_utf8( $dst->{ $k } );
-            delete $item->{'fetched'}{ lc $v->{'NAME'} };
-        }
-        $item->{'fetched'}{ lc $to } = 1;
-    }
-}
-
-sub GenerateDateFunction {
-    my $self = shift;
-    my %args = @_;
-
-    my $tz;
-    if ( RT->Config->Get('ChartsTimezonesInDB') ) {
-        my $to = $self->CurrentUser->UserObj->Timezone
-            || RT->Config->Get('Timezone');
-        $tz = { From => 'UTC', To => $to }
-            if $to && lc $to ne 'utc';
-    }
-
-    $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
-        Type     => $args{'SUBKEY'},
-        Field    => $self->NotSetDateToNullFunction,
-        Timezone => $tz,
-    );
-    return %args;
-}
-
-sub GenerateCustomFieldFunction {
-    my $self = shift;
-    my %args = @_;
-
-    my ($name) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
-    my $cf = RT::CustomField->new( $self->CurrentUser );
-    $cf->Load($name);
-    unless ( $cf->id ) {
-        $RT::Logger->error("Couldn't load CustomField #$name");
-        @args{qw(FUNCTION FIELD)} = ('NULL', undef);
-    } else {
-        my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf);
-        @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content');
-    }
-    return %args;
-}
-
-sub GenerateUserFunction {
-    my $self = shift;
-    my %args = @_;
-
-    my $column = $args{'SUBKEY'} || 'Name';
-    my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"}
-        ||= $self->Join(
-            TYPE   => 'LEFT',
-            ALIAS1 => 'main',
-            FIELD1 => $args{'FIELD'},
-            TABLE2 => 'Users',
-            FIELD2 => 'id',
-        );
-    @args{qw(ALIAS FIELD)} = ($u_alias, $column);
-    return %args;
-}
-
-sub GenerateWatcherFunction {
-    my $self = shift;
-    my %args = @_;
-
-    my $type = $args{'FIELD'};
-    $type = '' if $type eq 'Watcher';
-
-    my $single_role;
-
-    if ( $type =~ s!^CustomRole\.\{(\d+)\}!RT::CustomRole-$1! ) {
-        my $id = $1;
-        my $cr = RT::CustomRole->new( $self->CurrentUser );
-        $cr->Load($id);
-        $single_role = 1 if $cr->MaxValues;
-    }
-
-    my $column = $single_role ? $args{'SUBKEY'} || 'Name' : 'id';
-
-    my $alias = $self->{"_sql_report_watcher_alias_$type"};
-    unless ( $alias ) {
-        my $groups = $self->_RoleGroupsJoin(Name => $type);
-        my $group_members = $self->Join(
-            TYPE            => 'LEFT',
-            ALIAS1          => $groups,
-            FIELD1          => 'id',
-            TABLE2          => 'GroupMembers',
-            FIELD2          => 'GroupId',
-            ENTRYAGGREGATOR => 'AND',
-        );
-        $alias = $self->Join(
-            TYPE   => 'LEFT',
-            ALIAS1 => $group_members,
-            FIELD1 => 'MemberId',
-            TABLE2 => $single_role ? 'Users' : 'Principals',
-            FIELD2 => 'id',
-        );
-        $self->{"_sql_report_watcher_alias_$type"} = $alias;
-    }
-    @args{qw(ALIAS FIELD)} = ($alias, $column);
-
-    return %args;
-}
-
-sub DurationAsString {
-    my $self = shift;
-    my %args = @_;
-    my $v = $args{'VALUE'};
-    my $max_unit = $args{INFO} && ref $args{INFO}[-1] && $args{INFO}[-1]{business_time} ? 'hour' : 'year';
-
-    unless ( ref $v ) {
-        return $self->loc("(no value)") unless defined $v && length $v;
-        return RT::Date->new( $self->CurrentUser )->DurationAsString(
-            $v, Show => 3, Short => 1, MaxUnit => $max_unit,
-        );
-    }
-
-    my $date = RT::Date->new( $self->CurrentUser );
-    my %res = %$v;
-    foreach my $e ( values %res ) {
-        $e = $date->DurationAsString( $e, Short => 1, Show => 3, MaxUnit => $max_unit )
-            if defined $e && length $e;
-        $e = $self->loc("(no value)") unless defined $e && length $e;
-    }
-    return \%res;
-}
-
-sub LabelValueCode {
-    my $self = shift;
-    my $name = shift;
-
-    my $display = $self->ColumnInfo( $name )->{'META'}{'Display'};
-    return undef unless $display;
-    return $self->FindImplementationCode( $display );
-}
-
-
-sub FindImplementationCode {
-    my $self = shift;
-    my $value = shift;
-    my $silent = shift;
-
-    my $code;
-    unless ( $value ) {
-        $RT::Logger->error("Value is not defined. Should be method name or code reference")
-            unless $silent;
-        return undef;
-    }
-    elsif ( !ref $value ) {
-        $code = $self->can( $value );
-        unless ( $code ) {
-            $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" )
-                unless $silent;
-            return undef;
-        }
-    }
-    elsif ( ref( $value ) eq 'CODE' ) {
-        $code = $value;
-    }
-    else {
-        $RT::Logger->error("$value is not method name or code reference")
-            unless $silent;
-        return undef;
-    }
-    return $code;
-}
-
-sub Serialize {
-    my $self = shift;
-
-    my %clone = %$self;
-# current user, handle and column_info
-    delete @clone{'user', 'DBIxHandle', 'column_info'};
-    $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ];
-    $clone{'column_info'} = {};
-    while ( my ($k, $v) = each %{ $self->{'column_info'} } ) {
-        $clone{'column_info'}{$k} = { %$v };
-        delete $clone{'column_info'}{$k}{'META'};
-    }
-    return \%clone;
-}
-
-sub Deserialize {
-    my $self = shift;
-    my $data = shift;
-
-    $self->CleanSlate;
-    %$self = (%$self, %$data);
-
-    $self->{'items'} = [
-        map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r }
-        @{ $self->{'items'} }
-    ];
-    foreach my $e ( values %{ $self->{column_info} } ) {
-        $e->{'META'} = $e->{'TYPE'} eq 'grouping'
-            ? $GROUPINGS_META{ $e->{'INFO'} }
-            : $STATISTICS_META{ $e->{'INFO'}[1] }
-    }
-}
-
-
-sub FormatTable {
-    my $self = shift;
-    my %columns = @_;
-
-    my (@head, @body, @footer);
-
-    @head = ({ cells => []});
-    foreach my $column ( @{ $columns{'Groups'} } ) {
-        push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) };
-    }
-
-    my $i = 0;
-    while ( my $entry = $self->Next ) {
-        $body[ $i ] = { even => ($i+1)%2, cells => [] };
-        $i++;
-    }
-    @footer = ({ even => ++$i%2, cells => []}) if $self->{_distinct_results};
-
-    my $g = 0;
-    foreach my $column ( @{ $columns{'Groups'} } ) {
-        $i = 0;
-        my $last;
-        while ( my $entry = $self->Next ) {
-            my $value = $entry->LabelValue( $column );
-            if ( !$last || $last->{'value'} ne $value ) {
-                push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value };
-                $last->{even} = $g++ % 2
-                    unless $column eq $columns{'Groups'}[-1];
-            }
-            else {
-                $i++;
-                $last->{rowspan} = ($last->{rowspan}||1) + 1;
-            }
-        }
-    }
-    push @{ $footer[0]{'cells'} }, {
-        type => 'label',
-        value => $self->loc('Total'),
-        colspan => scalar @{ $columns{'Groups'} },
-    } if $self->{_distinct_results};
-
-    my $pick_color = do {
-        my @colors = RT->Config->Get("ChartColors");
-        sub { $colors[ $_[0] % @colors - 1 ] }
-    };
-
-    my $function_count = 0;
-    foreach my $column ( @{ $columns{'Functions'} } ) {
-        $i = 0;
-
-        my $info = $self->ColumnInfo( $column );
-
-        my @subs = ('');
-        if ( $info->{'META'}{'SubValues'} ) {
-            @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->(
-                $self
-            );
-        }
-
-        my %total;
-        unless ( $info->{'META'}{'NoTotals'} ) {
-            while ( my $entry = $self->Next ) {
-                my $raw = $entry->RawValue( $column ) || {};
-                $raw = { '' => $raw } unless ref $raw;
-                $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
-            }
-            @subs = grep $total{$_}, @subs
-                unless $info->{'META'}{'NoHideEmpty'};
-        }
-
-        my $label = $self->Label( $column );
-
-        unless (@subs) {
-            while ( my $entry = $self->Next ) {
-                push @{ $body[ $i++ ]{'cells'} }, {
-                    type => 'value',
-                    value => undef,
-                    query => $entry->Query,
-                };
-            }
-            push @{ $head[0]{'cells'} }, {
-                type => 'head',
-                value => $label,
-                rowspan => scalar @head,
-                color => $pick_color->(++$function_count),
-            };
-            push @{ $footer[0]{'cells'} }, { type => 'value', value => undef } if $self->{_distinct_results};
-            next;
-        }
-
-        if ( @subs > 1 && @head == 1 ) {
-            $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} };
-        }
-
-        if ( @subs == 1 ) {
-            push @{ $head[0]{'cells'} }, {
-                type => 'head',
-                value => $label,
-                rowspan => scalar @head,
-                color => $pick_color->(++$function_count),
-            };
-        } else {
-            push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs };
-            push @{ $head[1]{'cells'} }, { type => 'head', value => $_, color => $pick_color->(++$function_count) }
-                foreach @subs;
-        }
-
-        while ( my $entry = $self->Next ) {
-            my $query = $entry->Query;
-            my $value = $entry->LabelValue( $column ) || {};
-            $value = { '' => $value } unless ref $value;
-            foreach my $e ( @subs ) {
-                push @{ $body[ $i ]{'cells'} }, {
-                    type => 'value',
-                    value => $value->{ $e },
-                    query => $query,
-                };
-            }
-            $i++;
-        }
-
-        next unless $self->{_distinct_results};
-        unless ( $info->{'META'}{'NoTotals'} ) {
-            my $total_code = $self->LabelValueCode( $column );
-            foreach my $e ( @subs ) {
-                my $total = $total{ $e };
-                $total = $total_code->( $self, %$info, VALUE => $total )
-                    if $total_code;
-                push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
-            }
-        }
-        else {
-            foreach my $e ( @subs ) {
-                push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
-            }
-        }
-    }
-
-    return thead => \@head, tbody => \@body, tfoot => \@footer;
-}
-
-sub _CalculateTime {
-    my $self = shift;
-    my ( $type, $value, $current ) = @_;
-
-    return $current unless defined $value;
-
-    if ( $type eq 'SUM' ) {
-        $current += $value;
-    }
-    elsif ( $type eq 'AVG' ) {
-        $current ||= {};
-        $current->{total} += $value;
-        $current->{count}++;
-        $current->{calculate} ||= sub {
-            my $item = shift;
-            return sprintf '%.0f', $item->{total} / $item->{count};
-        };
-    }
-    elsif ( $type eq 'MAX' ) {
-        $current = $value unless $current && $current > $value;
-    }
-    elsif ( $type eq 'MIN' ) {
-        $current = $value unless $current && $current < $value;
-    }
-    else {
-        RT->Logger->error("Unsupported type $type");
-    }
-    return $current;
-}
-
-sub new {
-    my $self = shift;
-    $self->_SetupCustomDateRanges;
-    return $self->SUPER::new(@_);
-}
-
-
-sub _SetupCustomDateRanges {
-    my $self = shift;
-    my %names;
-
-    # Remove old custom date range groupings
-    for my $field ( grep {ref} @STATISTICS ) {
-        if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) {
-            $names{ $field->[2] } = 1;
-        }
-    }
-
-    my ( @new_groupings, @new_statistics );
-    while (@GROUPINGS) {
-        my $name = shift @GROUPINGS;
-        my $type = shift @GROUPINGS;
-        if ( !$names{$name} ) {
-            push @new_groupings, $name, $type;
-        }
-    }
-
-    while (@STATISTICS) {
-        my $key    = shift @STATISTICS;
-        my $info   = shift @STATISTICS;
-        my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/;
-        unless ( $name && $names{$name} ) {
-            push @new_statistics, $key, $info;
-        }
-    }
-
-    # Add new ones
-    my %ranges = RT::Ticket->CustomDateRanges;
-    for my $name ( sort keys %ranges ) {
-        my %extra_info;
-        my $spec = $ranges{$name};
-        if ( ref $spec && $spec->{business_time} ) {
-            $extra_info{business_time} = 1;
-        }
-
-        push @new_groupings, $name => $extra_info{business_time} ? 'DurationInBusinessHours' : 'Duration';
-        push @new_statistics,
-            (
-            "ALL($name)" => [ "Summary of $name", 'CustomDateRangeAll', $name, \%extra_info ],
-            "SUM($name)" => [ "Total $name",   'CustomDateRange', 'SUM', $name, \%extra_info ],
-            "AVG($name)" => [ "Average $name", 'CustomDateRange', 'AVG', $name, \%extra_info ],
-            "MIN($name)" => [ "Minimum $name", 'CustomDateRange', 'MIN', $name, \%extra_info ],
-            "MAX($name)" => [ "Maximum $name", 'CustomDateRange', 'MAX', $name, \%extra_info ],
-            );
-    }
-
-    @GROUPINGS  = @new_groupings;
-    @STATISTICS = @new_statistics;
-    %GROUPINGS  = %STATISTICS = ();
-
-    return 1;
-}
-
-sub _GroupingType {
-    my $self = shift;
-    my $key  = shift or return;
-    # keys for custom roles are like "CustomRole.{1}"
-    $key = 'CustomRole' if $key =~ /^CustomRole/;
-    return $GROUPINGS{$key};
-}
-
-sub DefaultGroupBy {
-    return 'Status';
-}
-
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 2f90254fbd..cf691d5a5b 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -51,159 +51,9 @@ package RT::Report::Tickets::Entry;
 use warnings;
 use strict;
 
-use base qw/RT::Record/;
+use base qw/RT::Report::Entry/;
 
-# XXX TODO: how the heck do we acl a report?
-sub CurrentUserHasRight {1}
-
-=head2 LabelValue
-
-If you're pulling a value out of this collection and using it as a label,
-you may want the "cleaned up" version.  This includes scrubbing 1970 dates
-and ensuring that dates are in local not DB timezones.
-
-=cut
-
-sub LabelValue {
-    my $self  = shift;
-    my $name = shift;
-
-    my $raw = $self->RawValue( $name, @_ );
-
-    if ( my $code = $self->Report->LabelValueCode( $name ) ) {
-        $raw = $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw );
-        return $self->loc('(no value)') unless defined $raw && length $raw;
-        return $raw;
-    }
-
-    unless ( ref $raw ) {
-        return $self->loc('(no value)') unless defined $raw && length $raw;
-        return $self->loc($raw) if $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
-        return $raw;
-    } else {
-        my $loc = $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
-        my %res = %$raw;
-        if ( $loc ) {
-            $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res;
-            $_ = $self->loc($_) foreach values %res;
-        }
-        $_ = $self->loc('(no value)') foreach grep !defined || !length, values %res;
-        return \%res;
-    }
-}
-
-sub RawValue {
-    return (shift)->__Value( @_ );
-}
-
-sub ObjectType {
-    return 'RT::Ticket';
-}
-
-sub CustomFieldLookupType {
-    RT::Ticket->CustomFieldLookupType
-}
-
-sub Query {
-    my $self = shift;
-
-    if ( my $ids = $self->{values}{ids} ) {
-        return join ' OR ', map "id=$_", @$ids;
-    }
-
-    my @parts;
-    foreach my $column ( $self->Report->ColumnsList ) {
-        my $info = $self->Report->ColumnInfo( $column );
-        next unless $info->{'TYPE'} eq 'grouping';
-
-        my $custom = $info->{'META'}{'Query'};
-        if ( $custom and my $code = $self->Report->FindImplementationCode( $custom ) ) {
-            push @parts, $code->( $self, COLUMN => $column, %$info );
-        }
-        else {
-            my $field = join '.', grep $_, $info->{KEY}, $info->{SUBKEY};
-            my $value = $self->RawValue( $column );
-            my $op = '=';
-            if ( defined $value ) {
-                if ( $info->{INFO} eq 'Watcher' && $info->{FIELD} eq 'id' ) {
-
-                    # convert id to name
-                    my $princ = RT::Principal->new( $self->CurrentUser );
-                    $princ->Load($value);
-                    $value = $princ->Object->Name if $princ->Object;
-                }
-
-                unless ( $value =~ /^\d+$/ ) {
-                    $value =~ s/(['\\])/\\$1/g;
-                    $value = "'$value'";
-                }
-            }
-            else {
-                ($op, $value) = ('IS', 'NULL');
-            }
-            unless ( $field =~ /^[{}\w\.]+$/ ) {
-                $field =~ s/(['\\])/\\$1/g;
-                $field = "'$field'";
-            }
-            push @parts, "$field $op $value";
-        }
-    }
-    return () unless @parts;
-    return join ' AND ', map "($_)", grep defined && length, @parts;
-}
-
-sub Report {
-    return $_[0]->{'report'};
-}
-
-sub DurationValue {
-    my $self  = shift;
-    my $value = $self->__Value(@_);
-
-    return 0 unless $value;
-
-    my $number;
-    my $unit;
-    if ( $value =~ /([\d,]+)(?:s| second)/ ) {
-        $number = $1;
-        $unit = 1;
-    }
-    elsif ( $value =~ /([\d,]+)(?:m| minute)/ ) {
-        $number = $1;
-        $unit = $RT::Date::MINUTE;
-    }
-    elsif ( $value =~ /([\d,]+)(?:h| hour)/ ) {
-        $number = $1;
-        $unit = $RT::Date::HOUR;
-    }
-    elsif ( $value =~ /([\d,]+)(?:d| day)/ ) {
-        $number = $1;
-        $unit = $RT::Date::DAY;
-    }
-    elsif ( $value =~ /([\d,]+)(?:W| week)/ ) {
-        $number = $1;
-        $unit = $RT::Date::WEEK;
-    }
-    elsif ( $value =~ /([\d,]+)(?:M| month)/ ) {
-        $number = $1;
-        $unit = $RT::Date::MONTH;
-    }
-    elsif ( $value =~ /([\d,]+)(?:Y| year)/ ) {
-        $number = $1;
-        $unit = $RT::Date::YEAR;
-    }
-    else {
-        return -.1; # Mark "(no value)" as -1 so it comes before 0
-    }
-
-    $number =~ s!,!!g;
-    my $seconds = $number * $unit;
-
-    if ( $value =~ /([<|>])/ ) {
-        $seconds += $1 eq '<' ? -1 : 1;
-    }
-    return $seconds;
-}
+sub ObjectType { 'RT::Ticket' }
 
 RT::Base->_ImportOverlays();
 

commit 1c67e3186d18a26fb5960335b850947dccedbdea
Author: sunnavy <sunnavy at 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 6b028bc653..4aa71696f4 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -701,7 +701,7 @@ sub SetupGroupings {
 
     my @group_by = grep defined && length,
         ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
-    @group_by = ('Status') unless @group_by;
+    @group_by = $self->DefaultGroupBy unless @group_by;
 
     my $distinct_results = 1;
     foreach my $e ( splice @group_by ) {
@@ -1741,6 +1741,10 @@ sub _GroupingType {
     return $GROUPINGS{$key};
 }
 
+sub DefaultGroupBy {
+    return 'Status';
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index caf1d035b1..f14851aa2a 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -267,6 +267,20 @@ sub RecordClass {
     $_[0]->_SingularClass
 }
 
+=head2 ReportClass
+
+Returns report class name of this collection. E.g. report class of RT::Tickets
+is RT::Report::Tickets
+
+=cut
+
+sub ReportClass {
+    my $self = shift;
+    my $class = ref($self) || $self;
+    $class =~ s/(?<=^RT::)/Report::/ or die "Cannot deduce ReportClass for $class";
+    return $class;
+}
+
 =head2 RegisterCustomFieldJoin
 
 Takes a pair of arguments, the first a class name and the second a callback
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index 95eca046e2..4c41d16302 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 939b874e6d..a37065bfe4 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -54,6 +54,7 @@ $ChartStyle => 'bar+table+sql'
 @ChartFunction => 'COUNT'
 $Width  => undef
 $Height => undef
+$Class => 'RT::Tickets'
 </%args>
 <%init>
 use GD;
@@ -101,8 +102,9 @@ my $plot_error = sub {
     $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 };
 
-use RT::Report::Tickets;
-my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require 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 6adc8356ab..71099e563b 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -46,9 +46,13 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%init>
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require or Abort( loc("Couldn't load [_1]", $report_class) );
+my $report = $report_class->new( $session{'CurrentUser'} );
+
 my $default_value = {
     Query => 'id > 0',
-    GroupBy => ['Status'],
+    GroupBy => [ $report->DefaultGroupBy ],
     ChartStyle => 'bar+table+sql',
     ChartFunction => ['COUNT'],
 };
@@ -57,7 +61,7 @@ $m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );
 
 my $title = loc( "Grouped search results");
 
-my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height ExtraQueryParams), @ExtraQueryParams );
+my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height Class ExtraQueryParams), @ExtraQueryParams );
 my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
     SearchType   => 'Chart',
     SearchFields => [@search_fields],
@@ -147,6 +151,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
 <form method="get" action="<% RT->Config->Get('WebPath') %>/Search/Chart.html">
 <input type="hidden" class="hidden" name="Query" value="<% $query{Query} %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
+<input type="hidden" class="hidden" name="Class" value="<% $Class %>" />
 
 % if ( $query{ExtraQueryParams} ) {
 %   for my $input ( ref $query{ExtraQueryParams} eq 'ARRAY' ?  @{$query{ExtraQueryParams}} : $query{ExtraQueryParams} ) {
@@ -165,6 +170,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
           Default => $query{'GroupBy'}[0],
           Stacked => $query{'GroupBy'}[0] eq ($query{StackedGroupBy} // '') ? 1 : 0,
           StackedId => 'StackedGroupBy-1',
+          Class => $Class,
         &>
         </fieldset>
         <fieldset><legend><% loc('and then') %></legend>
@@ -175,6 +181,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
           ShowEmpty => 1,
           Stacked => $query{'GroupBy'}[1] && ($query{'GroupBy'}[1] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
           StackedId => 'StackedGroupBy-2',
+          Class => $Class,
         &>
       </fieldset>
       <fieldset><legend><% loc('and then') %></legend>
@@ -185,19 +192,20 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
           ShowEmpty => 1,
           Stacked => $query{'GroupBy'}[2] && ($query{'GroupBy'}[2] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
           StackedId => 'StackedGroupBy-3',
+          Class => $Class,
         &>
       </fieldset>
     </&>
 
     <&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
       <fieldset><legend><% loc('Calculate values of') %></legend>
-        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0] &>
+        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0], Class => $Class, &>
       </fieldset>
       <fieldset><legend><% loc('and then') %></legend>
-        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1 &>
+        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1, Class => $Class, &>
       </fieldset>
       <fieldset><legend><% loc('and then') %></legend>
-        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1 &>
+        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1, Class => $Class, &>
       </fieldset>
     </&>
 
@@ -330,7 +338,7 @@ jQuery( function() {
 
   <div class="col-xl-6">
     <div class="saved-search">
-      <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
+      <& /Widgets/SavedSearch:show, Class => $Class, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
     </div>
   </div>
 </div>
@@ -340,4 +348,5 @@ jQuery( function() {
 
 <%ARGS>
 @ExtraQueryParams => ()
+$Class => 'RT::Tickets'
 </%ARGS>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 40905aa98a..a82ec48c25 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -50,11 +50,13 @@ $Query => "id > 0"
 @GroupBy => ()
 $ChartStyle => 'bar+table+sql'
 @ChartFunction => 'COUNT'
+$Class => 'RT::Tickets'
 </%args>
 <%init>
-use RT::Report::Tickets;
 
-my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require 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 008962e530..aced63a195 100644
--- a/share/html/Search/Elements/ChartTable
+++ b/share/html/Search/Elements/ChartTable
@@ -48,6 +48,7 @@
 <%ARGS>
 %Table => ()
 $Query => undef
+$Class => 'RT::Tickets'
 </%ARGS>
 <%INIT>
 
@@ -93,7 +94,7 @@ foreach my $section (qw(thead tbody tfoot)) {
                 if ( my $q = $cell->{'query'} ) {
                     $m->out(
                         '<a href="'. $eh->(RT->Config->Get('WebPath')) .'/Search/Results.html'
-                        .'?Query='. $eu->(join ' AND ', map "($_)", grep defined && length, $Query, $q)
+                        ."?Class=$Class&Query=". $eu->(join ' AND ', map "($_)", grep defined && length, $Query, $q)
                         . $eh->('&') . $base_query
                         . '">'
                     );
diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index b7d71f2db3..5c4d155097 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -104,7 +104,7 @@
   <div class="form-row">
     <div class="label col-4"><&|/l&>Load saved search</&>:</div>
     <div class="col-8 input-group">
-<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type &>
+<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@LoadObjects, SearchType => $Type, Class => $Class &>
 <input type="submit" class="button btn btn-primary" value="<% loc('Load') %>" id="SavedSearchLoadSubmit" name="SavedSearchLoadSubmit" />
     </div>
   </div>
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index 117fdb5c4c..419f6a00c0 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -72,8 +72,10 @@ while ( my ($value, $display) = splice @functions, 0, 2 ) {
 $Name => 'ChartFunction'
 $Default => 'COUNT'
 $ShowEmpty => 0
+$Class => $Class
 </%ARGS>
 <%INIT>
-my @functions = RT::Report::Tickets->Statistics;
-$Default = '' unless defined $Default;
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require 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 dc0e2e3394..ea41b4c8e2 100644
--- a/share/html/Search/Elements/SelectGroupBy
+++ b/share/html/Search/Elements/SelectGroupBy
@@ -47,11 +47,12 @@
 %# END BPS TAGGED BLOCK }}}
 <%args>
 $Name => 'GroupBy'
-$Default => 'Status'
+$Default => ''
 $Query   => ''
 $ShowEmpty => 0
 $Stacked => 0
 $StackedId => "Stacked$Name"
+$Class => 'RT::Tickets'
 </%args>
 <select name="<% $Name %>" class="cascade-by-optgroup">
 % if ( $ShowEmpty ) {
@@ -85,7 +86,8 @@ while ( my ($label, $value) = splice @options, 0, 2 ) {
 </span>
 
 <%init>
-use RT::Report::Tickets;
-my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require 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 a61311cd88..963de4df64 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 0cc40851fc..4cf2e03ff2 100644
--- a/share/html/Search/JSChart
+++ b/share/html/Search/JSChart
@@ -55,6 +55,7 @@ $Width  => undef
 $Height => undef
 $SavedSearchId => ''
 $StackedGroupBy => undef
+$Class => 'RT::Tickets'
 </%args>
 
 % my $id = join '-', 'search-chart', $SavedSearchId || ();
@@ -191,12 +192,11 @@ $Width  ||= ($ChartStyle =~ /\bpie\b/ ? 400 : 600);
 $Height ||= ($ChartStyle =~ /\bpie\b/ ? $Width : 400);
 $Height = $Width if $ChartStyle =~ /\bpie\b/;
 
-use RT::Report::Tickets;
-my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report_class = ( $Class || 'RT::Tickets' )->ReportClass;
+$report_class->require 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.
- at GroupBy = 'Status' unless @GroupBy;
+ at 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 42f9650fe1..3daf780551 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


More information about the rt-commit mailing list