[Rt-commit] rt branch 5.0/asset-search-chart created. rt-5.0.5-99-g4497a54780

BPS Git Server git at git.bestpractical.com
Wed Dec 20 15:20:15 UTC 2023


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/asset-search-chart has been created
        at  4497a547807f077964f8a068c010abd92f3e3932 (commit)

- Log -----------------------------------------------------------------
commit 4497a547807f077964f8a068c010abd92f3e3932
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Dec 5 10:10:12 2023 -0500

    Increase "Group By" rows to 5 to group by 2 more fields

diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 0733856126..14774cdb37 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -196,6 +196,28 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
           Class => $Class,
         &>
       </fieldset>
+      <fieldset><legend><% loc('and then') %></legend>
+        <& Elements/SelectGroupBy,
+          Name => 'GroupBy',
+          Query => $query{Query},
+          Default => $query{'GroupBy'}[3] // q{},
+          ShowEmpty => 1,
+          Stacked => $query{'GroupBy'}[3] && ($query{'GroupBy'}[3] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
+          StackedId => 'StackedGroupBy-4',
+          Class => $Class,
+        &>
+      </fieldset>
+      <fieldset><legend><% loc('and then') %></legend>
+        <& Elements/SelectGroupBy,
+          Name => 'GroupBy',
+          Query => $query{Query},
+          Default => $query{'GroupBy'}[4] // q{},
+          ShowEmpty => 1,
+          Stacked => $query{'GroupBy'}[4] && ($query{'GroupBy'}[4] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
+          StackedId => 'StackedGroupBy-5',
+          Class => $Class,
+        &>
+      </fieldset>
     </&>
 
     <&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>

commit 3eceb99fb4f5fdf9c3ce58f1d60fcbe5cbe1f4aa
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Dec 4 14:30:28 2023 -0500

    Test asset charts

diff --git a/t/charts/asset.t b/t/charts/asset.t
new file mode 100644
index 0000000000..767e53a13d
--- /dev/null
+++ b/t/charts/asset.t
@@ -0,0 +1,102 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+use RT::Report::Assets;
+
+for my $status (qw/new in-use in-use allocated/) {    # 2 in-use assets
+    create_asset( Catalog => 'General assets', Name => 'test', Status => $status );
+}
+
+my $report  = RT::Report::Assets->new( RT->SystemUser );
+my %columns = $report->SetupGroupings(
+    Query    => q{Catalog = 'General assets'},
+    GroupBy  => ['Status'],
+    Function => ['COUNT'],
+);
+$report->SortEntries;
+
+my @colors   = RT->Config->Get("ChartColors");
+my $expected = {
+    'thead' => [
+        {
+            'cells' => [
+                {
+                    'type'  => 'head',
+                    'value' => 'Status'
+                },
+                {
+                    'color'   => $colors[0],
+                    'rowspan' => 1,
+                    'type'    => 'head',
+                    'value'   => 'Asset count'
+                }
+            ]
+        }
+    ],
+    'tbody' => [
+        {
+            'cells' => [
+                {
+                    'type'  => 'label',
+                    'value' => 'allocated',
+                },
+                {
+                    'query' => "(Status = 'allocated')",
+                    'type'  => 'value',
+                    'value' => '1',
+                }
+            ],
+            'even' => 1
+        },
+        {
+            'even'  => 0,
+            'cells' => [
+                {
+                    'type'  => 'label',
+                    'value' => 'in-use',
+                },
+                {
+                    'query' => "(Status = 'in-use')",
+                    'type'  => 'value',
+                    'value' => '2',
+                }
+            ]
+        },
+        {
+            'even'  => 1,
+            'cells' => [
+                {
+                    'type'  => 'label',
+                    'value' => 'new',
+                },
+                {
+                    'query' => "(Status = 'new')",
+                    'type'  => 'value',
+                    'value' => '1',
+                }
+            ]
+        }
+    ],
+    'tfoot' => [
+        {
+            'cells' => [
+                {
+                    'colspan' => 1,
+                    'type'    => 'label',
+                    'value'   => 'Total'
+                },
+                {
+                    'type'  => 'value',
+                    'value' => 4
+                }
+            ],
+            'even' => 0
+        }
+    ],
+};
+
+my %table = $report->FormatTable(%columns);
+is_deeply( \%table, $expected, "basic table" );
+
+done_testing;
diff --git a/t/web/charting.t b/t/web/charting.t
index 1cf8170942..a9c8454cf9 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -141,4 +141,16 @@ $m->get_ok("/Search/Chart?Class=RT::Transactions&Query=Type=Create");
 is( $m->content_type, "image/png" );
 ok( length( $m->content ), "Has content" );
 
+# Test asset charts
+my $asset = RT::Asset->new( RT->SystemUser );
+$asset->Create( Name => 'test', Catalog => 'General assets', Status => 'new' );
+ok( $asset->Id, 'Created test asset' );
+$m->get_ok("/Search/Chart.html?Class=RT::Assets&Query=id>0");
+$m->content_like( qr{<th[^>]*>Status\s*</th>\s*<th[^>]*>Asset count\s*</th>}, "Grouped by status" );
+$m->content_like( qr{new\s*</th>\s*<td[^>]*>\s*<a[^>]*>1</a>},                "Found results in table" );
+$m->content_like( qr{<img src="/Search/Chart\?},                              "Found image" );
+$m->get_ok("/Search/Chart?Class=RT::Assets&Query=id>0");
+is( $m->content_type, "image/png" );
+ok( length( $m->content ), "Has content" );
+
 done_testing;
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index 5ed6b4964c..a634b9f200 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -205,12 +205,40 @@ $m->submit_form(
 );
 $m->content_contains("Chart first txn chart saved", 'saved first txn chart' );
 
+# Add asset saved searches
+$m->get_ok( $url . "/Search/Build.html?Class=RT::Assets&Query=" . 'id>0' );
+
+$m->submit_form(
+    form_name => 'BuildQuery',
+    fields    => {
+        SavedSearchDescription => 'first asset search',
+        SavedSearchOwner       => 'RT::System-1',
+    },
+    button => 'SavedSearchSave',
+);
+# We don't show saved message on page :/
+$m->content_contains("Save as New", 'saved first asset search' );
+
+$m->get_ok( $url . "/Search/Chart.html?Class=RT::Assets&Query=" . 'id>0' );
+
+$m->submit_form(
+    form_name => 'SaveSearch',
+    fields    => {
+        SavedSearchDescription => 'first asset chart',
+        SavedSearchOwner       => 'RT::System-1',
+    },
+    button => 'SavedSearchSave',
+);
+$m->content_contains("Chart first asset chart saved", 'saved first txn chart' );
+
 $m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
 push(
     @{$args->{body}},
     "saved-" . $m->dom->find('[data-description="first chart"]')->first->attr('data-name'),
     "saved-" . $m->dom->find('[data-description="first txn search"]')->first->attr('data-name'),
     "saved-" . $m->dom->find('[data-description="first txn chart"]')->first->attr('data-name'),
+    "saved-" . $m->dom->find('[data-description="first asset search"]')->first->attr('data-name'),
+    "saved-" . $m->dom->find('[data-description="first asset chart"]')->first->attr('data-name'),
 );
 
 $res = $m->post(
@@ -226,5 +254,8 @@ $m->text_contains('first chart');
 $m->text_contains('first txn search');
 $m->text_contains('first txn chart');
 $m->text_contains('Transaction count', 'txn chart content');
+$m->text_contains('first asset search');
+$m->text_contains('first asset chart');
+$m->text_contains('Asset count', 'asset chart content');
 
 done_testing;
diff --git a/t/web/saved_search_chart.t b/t/web/saved_search_chart.t
index 26366cc101..93fb297bd2 100644
--- a/t/web/saved_search_chart.t
+++ b/t/web/saved_search_chart.t
@@ -261,4 +261,34 @@ diag 'testing transaction saved searches';
     is( $search->Name, 'txn chart 1', 'loaded search' );
 }
 
+
+diag 'testing asset saved searches';
+{
+    $m->get_ok("/Search/Chart.html?Class=RT::Assets&Query=id>0");
+    $m->submit_form(
+        form_name => 'SaveSearch',
+        fields    => {
+            SavedSearchDescription => 'asset chart 1',
+            SavedSearchOwner       => $owner,
+        },
+        button => 'SavedSearchSave',
+    );
+    $m->form_name('SaveSearch');
+    @saved_search_ids = $m->current_form->find_input('SavedSearchLoad')->possible_values;
+    shift @saved_search_ids;    # first value is blank
+    my $chart_without_updates_id = $saved_search_ids[0];
+    ok( $chart_without_updates_id, 'got a saved chart id' );
+    is( scalar @saved_search_ids, 1, 'got only one saved chart id' );
+
+    my ( $privacy, $user_id, $search_id ) = $chart_without_updates_id =~ /^(RT::User-(\d+))-SavedSearch-(\d+)$/;
+    my $user = RT::User->new( RT->SystemUser );
+    $user->Load($user_id);
+    is( $user->Name, 'root', 'loaded user' );
+    my $currentuser = RT::CurrentUser->new($user);
+
+    my $search = RT::SavedSearch->new($currentuser);
+    $search->Load( $privacy, $search_id );
+    is( $search->Name, 'asset chart 1', 'loaded search' );
+}
+
 done_testing;

commit 5be73b5c830ac8fd7a9378ceb334b8151863167b
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Dec 1 11:58:08 2023 -0500

    Add asset charts support
    
    Here we also add Creator/LastUpdatedBy/Watcher search support to make
    links on charts work. E.g. if you group by Watcher, the AssetSQL on bars
    will be like:
    
        ... AND (Watcher.Name = 'root')

diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm
index 516869f9cc..30ab53629c 100644
--- a/lib/RT/Assets.pm
+++ b/lib/RT/Assets.pm
@@ -71,6 +71,8 @@ our %FIELD_METADATA = (
     Catalog          => [ 'ENUM' => 'Catalog', ], #loc_left_pair
     LastUpdated      => [ 'DATE'            => 'LastUpdated', ], #loc_left_pair
     Created          => [ 'DATE'            => 'Created', ], #loc_left_pair
+    Creator          => [ 'ENUM' => 'User', ], #loc_left_pair
+    LastUpdatedBy    => [ 'ENUM' => 'User', ], #loc_left_pair
 
     Linked           => [ 'LINK' ], #loc_left_pair
     LinkedTo         => [ 'LINK' => 'To' ], #loc_left_pair
@@ -90,6 +92,7 @@ our %FIELD_METADATA = (
     Contact          => [ 'WATCHERFIELD' => 'Contact', ], #loc_left_pair
     ContactGroup     => [ 'MEMBERSHIPFIELD' => 'Contact', ], #loc_left_pair
     CustomRole       => [ 'WATCHERFIELD' ], # loc_left_pair
+    Watcher          => [ 'WATCHERFIELD', ], #loc_left_pair
 
     CustomFieldValue => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
     CustomField      => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
@@ -396,7 +399,10 @@ sub AddRecord {
     my $asset = shift;
     return unless $asset->CurrentUserCanSee;
 
-    return if $asset->__Value('Status') eq 'deleted'
+    # No need to check "deleted" if it's from AssetSQL(_sql_query is set). This
+    # also short circuits Status check for RT::Report::Assets::Entry, which
+    # doesn't have Status column
+    return if !$self->{_sql_query} and $asset->__Value('Status') eq 'deleted'
         and not $self->{'allow_deleted_search'};
 
     $self->SUPER::AddRecord($asset, @_);
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 82f67b0018..6964b7b315 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -759,6 +759,7 @@ sub BuildMainNav {
             }
             elsif ( $class eq 'RT::Assets' ) {
                 $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" );
+                $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" );
             }
             elsif ( $class eq 'RT::Transactions' ) {
                 $current_search_menu->child( chart => title => loc('Chart'), path => "/Search/Chart.html$args" );
diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index 52b1b1c602..57b721c30f 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -1445,11 +1445,17 @@ sub _DoSearchInPerl {
     my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList;
     my %info;
 
-    my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) }
-        keys %{ RT->Config->Get('ServiceBusinessHours') || {} };
+    my %bh_class;
+
+    # Can't use ->can('SLA') as SLA is an autoloaded method of RT::Ticket
+    if ( $self->_SingularClass->ObjectType->_ClassAccessible->{SLA} ) {
+        %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) }
+           keys %{ RT->Config->Get('ServiceBusinessHours') || {} };
+    }
 
     while ( my $object = $objects->Next ) {
-        my $bh = $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : '';
+        my $bh = %bh_class
+            && $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : '';
 
         my @keys;
         my @extra_keys;
@@ -1802,8 +1808,15 @@ sub GetReferencedObjects {
     my $self = shift;
     my %args = @_;
 
-    my $class  = 'RT::Queue';
-    my $method = 'GetReferencedQueues';
+    my ( $class, $method );
+    if ( $self->isa('RT::Report::Assets') ) {
+        $class  = 'RT::Catalog';
+        $method = 'GetReferencedCatalogs';
+    }
+    else {
+        $class  = 'RT::Queue';
+        $method = 'GetReferencedQueues';
+    }
 
     my $objects;
     if ( $args{Query} ) {
diff --git a/lib/RT/Report/Assets.pm b/lib/RT/Report/Assets.pm
new file mode 100644
index 0000000000..a1590c3526
--- /dev/null
+++ b/lib/RT/Report/Assets.pm
@@ -0,0 +1,137 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2023 Best Practical Solutions, LLC
+#                                          <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::Assets;
+
+use base qw/RT::Report RT::Assets/;
+use RT::Report::Assets::Entry;
+
+use strict;
+use warnings;
+use 5.010;
+
+=head1 NAME
+
+RT::Report::Assets - Asset search charts
+
+=head1 DESCRIPTION
+
+This is the backend class for asset search charts.
+
+=cut
+
+our @GROUPINGS = (
+    Status        => 'Enum',            #loc_left_pair
+    Catalog       => 'Catalog',         #loc_left_pair
+    Creator       => 'User',            #loc_left_pair
+    LastUpdatedBy => 'User',            #loc_left_pair
+    Owner         => 'Watcher',         #loc_left_pair
+    HeldBy        => 'Watcher',         #loc_left_pair
+    Contact       => 'Watcher',         #loc_left_pair
+    Watcher       => 'Watcher',         #loc_left_pair
+    CustomRole    => 'Watcher',
+    Created       => 'Date',            #loc_left_pair
+    LastUpdated   => 'Date',            #loc_left_pair
+    CF            => 'CustomField',     #loc_left_pair
+);
+
+# loc'able strings below generated with (s/loq/loc/):
+#   perl -MRT=-init -MRT::Report::Assets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Assets::STATISTICS, 0, 2'
+#
+# loc("Asset count")
+# loc("Summary of Created to LastUpdated")
+# loc("Total Created to LastUpdated")
+# loc("Average Created to LastUpdated")
+# loc("Minimum Created to LastUpdated")
+# loc("Maximum Created to LastUpdated")
+
+our @STATISTICS = (
+    COUNT => ['Asset count', 'Count', 'id'],
+);
+
+foreach my $pair (
+    'Created to LastUpdated',
+) {
+    my ($from, $to) = split / to /, $pair;
+    push @STATISTICS, (
+        "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ],
+        "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ],
+        "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
+        "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
+        "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],
+    );
+    push @GROUPINGS, $pair => 'Duration';
+}
+
+sub _DoSearch {
+    my $self = shift;
+
+    # When groupby/calculation can't be done at SQL level, do it at Perl level
+    return $self->_DoSearchInPerl(@_) if $self->{_query};
+
+    $self->SUPER::_DoSearch( @_ );
+    $self->_PostSearch();
+}
+
+sub new {
+    my $self = shift;
+    $self->_SetupCustomDateRanges;
+    return $self->SUPER::new(@_);
+}
+
+sub _Init {
+    my $self = shift;
+    $self->SUPER::_Init(@_);
+
+    # Reset OrderBy to not order by name by default
+    $self->OrderByCols();
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Report/Assets/Entry.pm b/lib/RT/Report/Assets/Entry.pm
new file mode 100644
index 0000000000..1a22edd29d
--- /dev/null
+++ b/lib/RT/Report/Assets/Entry.pm
@@ -0,0 +1,58 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2023 Best Practical Solutions, LLC
+#                                          <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::Assets::Entry;
+
+use warnings;
+use strict;
+
+use base qw/RT::Report::Entry/;
+
+RT::Base->_ImportOverlays();
+
+1;

commit 1ce3ff95a5b1cbfa624697239284445c304ec85a
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Dec 5 11:25:52 2023 -0500

    Move a few more general code from RT::Report::Tickets to RT::Report
    
    We are going to reuse them in the upcoming asset charts.

diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index 3db792e31f..52b1b1c602 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -559,6 +559,71 @@ sub SetupGroupings {
         @_
     );
 
+    $self->FromSQL( $args{'Query'} ) if $args{'Query'};
+
+    # Apply ACL checks
+    $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
+
+    # See if our query is distinct
+    if (not $self->{'joins_are_distinct'} and $self->_isJoined) {
+        # If it isn't, we need to do this in two stages -- first, find
+        # the distinct matching tickets (with no group by), then search
+        # within the matching tickets grouped by what is wanted.
+        $self->Columns( 'id' );
+        if ( RT->Config->Get('UseSQLForACLChecks') ) {
+            my $query = $self->BuildSelectQuery( PreferBind => 0 );
+            $self->CleanSlate;
+            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 );
+        }
+        else {
+            # ACL is done in Next call
+            my @match = (0);
+            while ( my $row = $self->Next ) {
+                push @match, $row->id;
+            }
+
+            # Replace the query with one that matches precisely those
+            # tickets, with no joins.  We then mark it as having been ACL'd,
+            # since it was by dint of being in the search results above
+            $self->CleanSlate;
+            while ( @match > 1000 ) {
+                my @batch = splice( @match, 0, 1000 );
+                $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
+            }
+            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
+        }
+        $self->{'_sql_current_user_can_see_applied'} = 1
+    }
+
+    my %res = $self->_SetupGroupings(%args);
+
+    if ($args{Query}
+        && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } )
+            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ }
+                values %{ $self->{column_info} } )
+            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} }
+                values %{ $self->{column_info} } ) )
+       )
+    {
+        # Need to do the groupby/calculation at Perl level
+        $self->{_query} = $args{'Query'};
+    }
+    else {
+        delete $self->{_query};
+    }
+
+    return %res;
+}
+
+sub _SetupGroupings {
+    my $self = shift;
+    my %args = (
+        Query => undef,
+        GroupBy => undef,
+        Function => undef,
+        @_
+    );
+
     my $i = 0;
 
     my @group_by = grep defined && length,
@@ -1366,6 +1431,324 @@ sub DefaultGroupBy {
 
 # The following methods are more collection related
 
+=head2 _DoSearchInPerl
+
+For complicated reports that can't be calculated in SQL, do them in Perl.
+
+=cut
+
+sub _DoSearchInPerl {
+    my $self = shift;
+
+    my $objects = $self->_CollectionClass->new( $self->CurrentUser );
+    $objects->FromSQL( $self->{_query} );
+    my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList;
+    my %info;
+
+    my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) }
+        keys %{ RT->Config->Get('ServiceBusinessHours') || {} };
+
+    while ( my $object = $objects->Next ) {
+        my $bh = $object->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $object->SLA }{BusinessHours} : '';
+
+        my @keys;
+        my @extra_keys;
+        my %css_class;
+        for my $group ( @groups ) {
+            my $value;
+
+            if ( $object->_Accessible($group->{KEY}, 'read' )) {
+                if ( $group->{SUBKEY} ) {
+                    my $method = "$group->{KEY}Obj";
+                    if ( my $obj = $object->$method ) {
+                        if ( $group->{INFO} eq 'Date' ) {
+                            if ( $obj->Unix > 0 ) {
+                                $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} },
+                                    Timezone => 'user' );
+                            }
+                            else {
+                                $value = $self->loc('(no value)')
+                            }
+                        }
+                        else {
+                            $value = $obj->_Value($group->{SUBKEY});
+                        }
+                        $value //= $self->loc('(no value)');
+                    }
+                }
+                $value //= $object->_Value( $group->{KEY} ) // $self->loc('(no value)');
+            }
+            elsif ( $group->{INFO} eq 'Watcher' ) {
+                my @values;
+                if ( $object->can($group->{KEY}) ) {
+                    my $method = $group->{KEY};
+                    push @values, map { $_->MemberId } @{$object->$method->MembersObj->ItemsArrayRef};
+                }
+                elsif ( $group->{KEY} eq 'Watcher' ) {
+                    push @values, map { $_->MemberId } @{$object->$_->MembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/;
+                }
+                else {
+                    RT->Logger->error("Unsupported group by $group->{KEY}");
+                    next;
+                }
+
+                @values = $self->loc('(no value)') unless @values;
+                $value = \@values;
+            }
+            elsif ( $group->{INFO} eq 'CustomField' ) {
+                my ($id) = $group->{SUBKEY} =~ /{(\d+)}/;
+                my $values = $object->CustomFieldValues($id);
+                if ( $values->Count ) {
+                    $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ];
+                }
+                else {
+                    $value = $self->loc('(no value)');
+                }
+            }
+            elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) {
+                my $business_time = $1;
+
+                if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) {
+                    my $start        = $1;
+                    my $end          = $2;
+                    my $start_method = $start . 'Obj';
+                    my $end_method   = $end . 'Obj';
+                    if ( $object->$end_method->Unix > 0 && $object->$start_method->Unix > 0 ) {
+                        my $seconds;
+
+                        if ($business_time) {
+                            $seconds = $object->CustomDateRange(
+                                '',
+                                {   value         => "$end - $start",
+                                    business_time => 1,
+                                    format        => sub { $_[0] },
+                                }
+                            );
+                        }
+                        else {
+                            $seconds = $object->$end_method->Unix - $object->$start_method->Unix;
+                        }
+
+                        if ( $group->{SUBKEY} eq 'Default' ) {
+                            $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
+                                $seconds,
+                                Show    => $group->{META}{Show},
+                                Short   => $group->{META}{Short},
+                                MaxUnit => $business_time ? 'hour' : 'year',
+                            );
+                        }
+                        else {
+                            $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
+                                $seconds,
+                                Show    => $group->{META}{Show} // 3,
+                                Short   => $group->{META}{Short} // 1,
+                                MaxUnit => lc $group->{SUBKEY},
+                                MinUnit => lc $group->{SUBKEY},
+                                Unit    => lc $group->{SUBKEY},
+                            );
+                        }
+                    }
+
+                    if ( $business_time ) {
+                        push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
+                    }
+                }
+                else {
+                    my %ranges = $self->_RoleGroupClass->CustomDateRanges;
+                    if ( my $spec = $ranges{$group->{FIELD}} ) {
+                        if ( $group->{SUBKEY} eq 'Default' ) {
+                            $value = $object->CustomDateRange( $group->{FIELD}, $spec );
+                        }
+                        else {
+                            my $seconds = $object->CustomDateRange( $group->{FIELD},
+                                { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } );
+
+                            if ( defined $seconds ) {
+                                $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
+                                    $seconds,
+                                    Show    => $group->{META}{Show} // 3,
+                                    Short   => $group->{META}{Short} // 1,
+                                    MaxUnit => lc $group->{SUBKEY},
+                                    MinUnit => lc $group->{SUBKEY},
+                                    Unit    => lc $group->{SUBKEY},
+                                );
+                            }
+                        }
+                        if ( ref $spec && $spec->{business_time} ) {
+                            # 1 means the corresponding one in SLA, which $bh already holds
+                            $bh = $spec->{business_time} unless $spec->{business_time} eq '1';
+                            push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
+                        }
+                    }
+                }
+
+                $value //= $self->loc('(no value)');
+            }
+            else {
+                RT->Logger->error("Unsupported group by $group->{KEY}");
+                next;
+            }
+            push @keys, $value;
+        }
+        push @keys, @extra_keys;
+
+        # @keys could contain arrayrefs, so we need to expand it.
+        # e.g. "open", [ "root", "foo" ], "General" )
+        # will be expanded to:
+        #   "open", "root", "General"
+        #   "open", "foo", "General"
+
+        my @all_keys;
+        for my $key (@keys) {
+            if ( ref $key eq 'ARRAY' ) {
+                if (@all_keys) {
+                    my @new_all_keys;
+                    for my $keys ( @all_keys ) {
+                        push @new_all_keys, [ @$keys, $_ ] for @$key;
+                    }
+                    @all_keys = @new_all_keys;
+                }
+                else {
+                    push @all_keys, [$_] for @$key;
+                }
+            }
+            else {
+                if (@all_keys) {
+                    @all_keys = map { [ @$_, $key ] } @all_keys;
+                }
+                else {
+                    push @all_keys, [$key];
+                }
+            }
+        }
+
+        my @fields = grep { $_->{TYPE} eq 'statistic' }
+            map { $self->ColumnInfo($_) } $self->ColumnsList;
+
+        while ( my $field = shift @fields ) {
+            for my $keys (@all_keys) {
+                my $key = join ';;;', @$keys;
+                if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) {
+                    $info{$key}{ $field->{NAME} }++;
+                }
+                elsif ( $field->{NAME} =~ /^postfunction/ ) {
+                    if ( $field->{MAP} ) {
+                        my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/;
+                        for my $item ( values %{ $field->{MAP} } ) {
+                            push @fields,
+                                {
+                                NAME  => $item->{NAME},
+                                FIELD => $item->{FIELD},
+                                INFO  => [
+                                    '', $meta_type,
+                                    $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '',
+                                    @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ],
+                                ],
+                                };
+                        }
+                    }
+                }
+                elsif ( $field->{INFO}[1] eq 'Time' ) {
+                    if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) {
+                        my $method = $1;
+                        my $type   = $field->{INFO}[2];
+                        my $name   = lc $field->{NAME};
+
+                        $info{$key}{$name}
+                            = $self->_CalculateTime( $type, $object->$method * 60, $info{$key}{$name} ) || 0;
+                    }
+                    else {
+                        RT->Logger->error("Unsupported field $field->{NAME}");
+                    }
+                }
+                elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) {
+                    my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} };
+                    my $name = lc $field->{NAME};
+                    $info{$key}{$name} ||= 0;
+
+                    my $start_method = $start . 'Obj';
+                    my $end_method   = $end . 'Obj';
+                    next unless $object->$end_method->Unix > 0 && $object->$start_method->Unix > 0;
+
+                    my $value;
+                    if ($extra_info->{business_time}) {
+                        $value = $object->CustomDateRange(
+                            '',
+                            {   value         => "$end - $start",
+                                business_time => $extra_info->{business_time},
+                                format        => sub { return $_[0] },
+                            }
+                        );
+                    }
+                    else {
+                        $value = $object->$end_method->Unix - $object->$start_method->Unix;
+                    }
+
+                    $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
+                }
+                elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) {
+                    my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} };
+                    my $name = lc $field->{NAME};
+                    $info{$key}{$name} ||= 0;
+
+                    my $value;
+                    my %ranges = $self->_RoleGroupClass->CustomDateRanges;
+                    if ( my $spec = $ranges{$range_name} ) {
+                        $value = $object->CustomDateRange(
+                            $range_name,
+                            {
+                                ref $spec eq 'HASH' ? %$spec : ( value => $spec ),
+                                format => sub { $_[0] },
+                            }
+                        );
+                    }
+                    $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
+                }
+                else {
+                    RT->Logger->error("Unsupported field $field->{INFO}[1]");
+                }
+            }
+        }
+
+        for my $keys (@all_keys) {
+            my $key = join ';;;', @$keys;
+            push @{ $info{$key}{ids} }, $object->id;
+        }
+    }
+
+    # Make generated results real SB results
+    for my $key ( keys %info ) {
+        my @keys = split /;;;/, $key;
+        my $row;
+        for my $group ( @groups ) {
+            $row->{lc $group->{NAME}} = shift @keys;
+        }
+        for my $field ( keys %{ $info{$key} } ) {
+            my $value = $info{$key}{$field};
+            if ( ref $value eq 'HASH' && $value->{calculate} ) {
+                $row->{$field} = $value->{calculate}->($value);
+            }
+            else {
+                $row->{$field} = $info{$key}{$field};
+            }
+        }
+        my $item = $self->NewItem();
+
+        # Has extra css info
+        for my $key (@keys) {
+            if ( $key =~ /(.+) => (.+)/ ) {
+                $row->{_css_class}{$1} = $2;
+            }
+        }
+
+        $item->LoadFromHash($row);
+        $self->AddRecord($item);
+    }
+    $self->{must_redo_search} = 0;
+    $self->{is_limited} = 1;
+    $self->PostProcessRecords;
+}
+
 sub _PostSearch {
     my $self = shift;
     if ( $self->{'must_redo_search'} ) {
@@ -1378,6 +1761,14 @@ sub _PostSearch {
     }
 }
 
+
+# Gotta skip over customized Next, since it does all sorts of crazy magic we don't want.
+sub Next {
+    my $self = shift;
+    $self->RT::SearchBuilder::Next(@_);
+
+}
+
 sub NewItem {
     my $self = shift;
     my $res = $self->_SingularClass->new($self->CurrentUser);
@@ -1478,6 +1869,13 @@ sub GetCustomRoles {
     return $custom_roles;
 }
 
+sub _CollectionClass {
+    my $self = shift;
+    my $class = ref $self || $self;
+    $class =~ s!::Report!!;
+    return $class;
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 53d9424b55..6fdd51a755 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -210,71 +210,6 @@ foreach my $pair (
     }
 }
 
-sub SetupGroupings {
-    my $self = shift;
-    my %args = (
-        Query => undef,
-        GroupBy => undef,
-        Function => undef,
-        @_
-    );
-
-    $self->FromSQL( $args{'Query'} ) if $args{'Query'};
-
-    # Apply ACL checks
-    $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
-
-    # See if our query is distinct
-    if (not $self->{'joins_are_distinct'} and $self->_isJoined) {
-        # If it isn't, we need to do this in two stages -- first, find
-        # the distinct matching tickets (with no group by), then search
-        # within the matching tickets grouped by what is wanted.
-        $self->Columns( 'id' );
-        if ( RT->Config->Get('UseSQLForACLChecks') ) {
-            my $query = $self->BuildSelectQuery( PreferBind => 0 );
-            $self->CleanSlate;
-            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => "($query)", QUOTEVALUE => 0 );
-        }
-        else {
-            # ACL is done in Next call
-            my @match = (0);
-            while ( my $row = $self->Next ) {
-                push @match, $row->id;
-            }
-
-            # Replace the query with one that matches precisely those
-            # tickets, with no joins.  We then mark it as having been ACL'd,
-            # since it was by dint of being in the search results above
-            $self->CleanSlate;
-            while ( @match > 1000 ) {
-                my @batch = splice( @match, 0, 1000 );
-                $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
-            }
-            $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
-        }
-        $self->{'_sql_current_user_can_see_applied'} = 1
-    }
-
-    my %res = $self->SUPER::SetupGroupings(%args);
-
-    if ($args{Query}
-        && ( grep( { $_->{INFO} =~ /Duration|CustomDateRange/ } map { $self->{column_info}{$_} } @{ $res{Groups} } )
-            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && $_->{INFO}[1] =~ /CustomDateRange/ }
-                values %{ $self->{column_info} } )
-            || grep( { $_->{TYPE} eq 'statistic' && ref $_->{INFO} && ref $_->{INFO}[-1] && $_->{INFO}[-1]{business_time} }
-                values %{ $self->{column_info} } ) )
-       )
-    {
-        # Need to do the groupby/calculation at Perl level
-        $self->{_query} = $args{'Query'};
-    }
-    else {
-        delete $self->{_query};
-    }
-
-    return %res;
-}
-
 =head2 _DoSearch
 
 Subclass _DoSearch from our parent so we can go through and add in empty
@@ -290,317 +225,7 @@ sub _DoSearch {
     my $self = shift;
 
     # When groupby/calculation can't be done at SQL level, do it at Perl level
-    if ( $self->{_query} ) {
-        my $tickets = RT::Tickets->new( $self->CurrentUser );
-        $tickets->FromSQL( $self->{_query} );
-        my @groups = grep { $_->{TYPE} eq 'grouping' } map { $self->ColumnInfo($_) } $self->ColumnsList;
-        my %info;
-
-        my %bh_class = map { $_ => 'business_hours_' . HTML::Mason::Commands::CSSClass( lc $_ ) }
-            keys %{ RT->Config->Get('ServiceBusinessHours') || {} };
-
-        while ( my $ticket = $tickets->Next ) {
-            my $bh = $ticket->SLA ? RT->Config->Get('ServiceAgreements')->{Levels}{ $ticket->SLA }{BusinessHours} : '';
-
-            my @keys;
-            my @extra_keys;
-            my %css_class;
-            for my $group ( @groups ) {
-                my $value;
-
-                if ( $ticket->_Accessible($group->{KEY}, 'read' )) {
-                    if ( $group->{SUBKEY} ) {
-                        my $method = "$group->{KEY}Obj";
-                        if ( my $obj = $ticket->$method ) {
-                            if ( $group->{INFO} eq 'Date' ) {
-                                if ( $obj->Unix > 0 ) {
-                                    $value = $obj->Strftime( $self->_GroupingsMeta()->{Date}{StrftimeFormat}{ $group->{SUBKEY} },
-                                        Timezone => 'user' );
-                                }
-                                else {
-                                    $value = $self->loc('(no value)')
-                                }
-                            }
-                            else {
-                                $value = $obj->_Value($group->{SUBKEY});
-                            }
-                            $value //= $self->loc('(no value)');
-                        }
-                    }
-                    $value //= $ticket->_Value( $group->{KEY} ) // $self->loc('(no value)');
-                }
-                elsif ( $group->{INFO} eq 'Watcher' ) {
-                    my @values;
-                    if ( $ticket->can($group->{KEY}) ) {
-                        my $method = $group->{KEY};
-                        push @values, map { $_->MemberId } @{$ticket->$method->MembersObj->ItemsArrayRef};
-                    }
-                    elsif ( $group->{KEY} eq 'Watcher' ) {
-                        push @values, map { $_->MemberId } @{$ticket->$_->MembersObj->ItemsArrayRef} for /Requestor Cc AdminCc/;
-                    }
-                    else {
-                        RT->Logger->error("Unsupported group by $group->{KEY}");
-                        next;
-                    }
-
-                    @values = $self->loc('(no value)') unless @values;
-                    $value = \@values;
-                }
-                elsif ( $group->{INFO} eq 'CustomField' ) {
-                    my ($id) = $group->{SUBKEY} =~ /{(\d+)}/;
-                    my $values = $ticket->CustomFieldValues($id);
-                    if ( $values->Count ) {
-                        $value = [ map { $_->Content } @{ $values->ItemsArrayRef } ];
-                    }
-                    else {
-                        $value = $self->loc('(no value)');
-                    }
-                }
-                elsif ( $group->{INFO} =~ /^Duration(InBusinessHours)?/ ) {
-                    my $business_time = $1;
-
-                    if ( $group->{FIELD} =~ /^(\w+) to (\w+)(\(Business Hours\))?$/ ) {
-                        my $start        = $1;
-                        my $end          = $2;
-                        my $start_method = $start . 'Obj';
-                        my $end_method   = $end . 'Obj';
-                        if ( $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0 ) {
-                            my $seconds;
-
-                            if ($business_time) {
-                                $seconds = $ticket->CustomDateRange(
-                                    '',
-                                    {   value         => "$end - $start",
-                                        business_time => 1,
-                                        format        => sub { $_[0] },
-                                    }
-                                );
-                            }
-                            else {
-                                $seconds = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
-                            }
-
-                            if ( $group->{SUBKEY} eq 'Default' ) {
-                                $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                    $seconds,
-                                    Show    => $group->{META}{Show},
-                                    Short   => $group->{META}{Short},
-                                    MaxUnit => $business_time ? 'hour' : 'year',
-                                );
-                            }
-                            else {
-                                $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                    $seconds,
-                                    Show    => $group->{META}{Show} // 3,
-                                    Short   => $group->{META}{Short} // 1,
-                                    MaxUnit => lc $group->{SUBKEY},
-                                    MinUnit => lc $group->{SUBKEY},
-                                    Unit    => lc $group->{SUBKEY},
-                                );
-                            }
-                        }
-
-                        if ( $business_time ) {
-                            push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
-                        }
-                    }
-                    else {
-                        my %ranges = RT::Ticket->CustomDateRanges;
-                        if ( my $spec = $ranges{$group->{FIELD}} ) {
-                            if ( $group->{SUBKEY} eq 'Default' ) {
-                                $value = $ticket->CustomDateRange( $group->{FIELD}, $spec );
-                            }
-                            else {
-                                my $seconds = $ticket->CustomDateRange( $group->{FIELD},
-                                    { ref $spec ? %$spec : ( value => $spec ), format => sub { $_[0] } } );
-
-                                if ( defined $seconds ) {
-                                    $value = RT::Date->new( $self->CurrentUser )->DurationAsString(
-                                        $seconds,
-                                        Show    => $group->{META}{Show} // 3,
-                                        Short   => $group->{META}{Short} // 1,
-                                        MaxUnit => lc $group->{SUBKEY},
-                                        MinUnit => lc $group->{SUBKEY},
-                                        Unit    => lc $group->{SUBKEY},
-                                    );
-                                }
-                            }
-                            if ( ref $spec && $spec->{business_time} ) {
-                                # 1 means the corresponding one in SLA, which $bh already holds
-                                $bh = $spec->{business_time} unless $spec->{business_time} eq '1';
-                                push @extra_keys, join ' => ', $group->{FIELD}, $bh_class{$bh} || 'business_hours_none';
-                            }
-                        }
-                    }
-
-                    $value //= $self->loc('(no value)');
-                }
-                else {
-                    RT->Logger->error("Unsupported group by $group->{KEY}");
-                    next;
-                }
-                push @keys, $value;
-            }
-            push @keys, @extra_keys;
-
-            # @keys could contain arrayrefs, so we need to expand it.
-            # e.g. "open", [ "root", "foo" ], "General" )
-            # will be expanded to:
-            #   "open", "root", "General"
-            #   "open", "foo", "General"
-
-            my @all_keys;
-            for my $key (@keys) {
-                if ( ref $key eq 'ARRAY' ) {
-                    if (@all_keys) {
-                        my @new_all_keys;
-                        for my $keys ( @all_keys ) {
-                            push @new_all_keys, [ @$keys, $_ ] for @$key;
-                        }
-                        @all_keys = @new_all_keys;
-                    }
-                    else {
-                        push @all_keys, [$_] for @$key;
-                    }
-                }
-                else {
-                    if (@all_keys) {
-                        @all_keys = map { [ @$_, $key ] } @all_keys;
-                    }
-                    else {
-                        push @all_keys, [$key];
-                    }
-                }
-            }
-
-            my @fields = grep { $_->{TYPE} eq 'statistic' }
-                map { $self->ColumnInfo($_) } $self->ColumnsList;
-
-            while ( my $field = shift @fields ) {
-                for my $keys (@all_keys) {
-                    my $key = join ';;;', @$keys;
-                    if ( $field->{NAME} =~ /^id/ && $field->{FUNCTION} eq 'COUNT' ) {
-                        $info{$key}{ $field->{NAME} }++;
-                    }
-                    elsif ( $field->{NAME} =~ /^postfunction/ ) {
-                        if ( $field->{MAP} ) {
-                            my ($meta_type) = $field->{INFO}[1] =~ /^(\w+)All$/;
-                            for my $item ( values %{ $field->{MAP} } ) {
-                                push @fields,
-                                    {
-                                    NAME  => $item->{NAME},
-                                    FIELD => $item->{FIELD},
-                                    INFO  => [
-                                        '', $meta_type,
-                                        $item->{FUNCTION} =~ /^(\w+)/ ? $1 : '',
-                                        @{ $field->{INFO} }[ 2 .. $#{ $field->{INFO} } ],
-                                    ],
-                                    };
-                            }
-                        }
-                    }
-                    elsif ( $field->{INFO}[1] eq 'Time' ) {
-                        if ( $field->{NAME} =~ /^(TimeWorked|TimeEstimated|TimeLeft)$/ ) {
-                            my $method = $1;
-                            my $type   = $field->{INFO}[2];
-                            my $name   = lc $field->{NAME};
-
-                            $info{$key}{$name}
-                                = $self->_CalculateTime( $type, $ticket->$method * 60, $info{$key}{$name} ) || 0;
-                        }
-                        else {
-                            RT->Logger->error("Unsupported field $field->{NAME}");
-                        }
-                    }
-                    elsif ( $field->{INFO}[1] eq 'DateTimeInterval' ) {
-                        my ( undef, undef, $type, $start, $end, $extra_info ) = @{ $field->{INFO} };
-                        my $name = lc $field->{NAME};
-                        $info{$key}{$name} ||= 0;
-
-                        my $start_method = $start . 'Obj';
-                        my $end_method   = $end . 'Obj';
-                        next unless $ticket->$end_method->Unix > 0 && $ticket->$start_method->Unix > 0;
-
-                        my $value;
-                        if ($extra_info->{business_time}) {
-                            $value = $ticket->CustomDateRange(
-                                '',
-                                {   value         => "$end - $start",
-                                    business_time => $extra_info->{business_time},
-                                    format        => sub { return $_[0] },
-                                }
-                            );
-                        }
-                        else {
-                            $value = $ticket->$end_method->Unix - $ticket->$start_method->Unix;
-                        }
-
-                        $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
-                    }
-                    elsif ( $field->{INFO}[1] eq 'CustomDateRange' ) {
-                        my ( undef, undef, $type, $range_name ) = @{ $field->{INFO} };
-                        my $name = lc $field->{NAME};
-                        $info{$key}{$name} ||= 0;
-
-                        my $value;
-                        my %ranges = RT::Ticket->CustomDateRanges;
-                        if ( my $spec = $ranges{$range_name} ) {
-                            $value = $ticket->CustomDateRange(
-                                $range_name,
-                                {
-                                    ref $spec eq 'HASH' ? %$spec : ( value => $spec ),
-                                    format => sub { $_[0] },
-                                }
-                            );
-                        }
-                        $info{$key}{$name} = $self->_CalculateTime( $type, $value, $info{$key}{$name} );
-                    }
-                    else {
-                        RT->Logger->error("Unsupported field $field->{INFO}[1]");
-                    }
-                }
-            }
-
-            for my $keys (@all_keys) {
-                my $key = join ';;;', @$keys;
-                push @{ $info{$key}{ids} }, $ticket->id;
-            }
-        }
-
-        # Make generated results real SB results
-        for my $key ( keys %info ) {
-            my @keys = split /;;;/, $key;
-            my $row;
-            for my $group ( @groups ) {
-                $row->{lc $group->{NAME}} = shift @keys;
-            }
-            for my $field ( keys %{ $info{$key} } ) {
-                my $value = $info{$key}{$field};
-                if ( ref $value eq 'HASH' && $value->{calculate} ) {
-                    $row->{$field} = $value->{calculate}->($value);
-                }
-                else {
-                    $row->{$field} = $info{$key}{$field};
-                }
-            }
-            my $item = $self->NewItem();
-
-            # Has extra css info
-            for my $key (@keys) {
-                if ( $key =~ /(.+) => (.+)/ ) {
-                    $row->{_css_class}{$1} = $2;
-                }
-            }
-
-            $item->LoadFromHash($row);
-            $self->AddRecord($item);
-        }
-        $self->{must_redo_search} = 0;
-        $self->{is_limited} = 1;
-        $self->PostProcessRecords;
-
-        return;
-    }
+    return $self->_DoSearchInPerl(@_) if $self->{_query};
 
     $self->SUPER::_DoSearch( @_ );
     $self->_PostSearch();
diff --git a/lib/RT/Report/Transactions.pm b/lib/RT/Report/Transactions.pm
index 3d285befc2..64557057a4 100644
--- a/lib/RT/Report/Transactions.pm
+++ b/lib/RT/Report/Transactions.pm
@@ -115,7 +115,7 @@ sub SetupGroupings {
         $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
     }
 
-    return $self->SUPER::SetupGroupings(%args);
+    return $self->_SetupGroupings(%args);
 }
 
 sub _DoSearch {

commit 73b1fb96a09835dac2a36826f251566cef2eef18
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Dec 1 13:49:49 2023 -0500

    Abstract procedures to get queue-specific custom fields and roles
    
    With this abstraction, we will be able to add corresponding support to
    asset charts easily, where we need to get catalog-specific custom fields
    and roles.

diff --git a/lib/RT/Report.pm b/lib/RT/Report.pm
index 916ae47a0c..3db792e31f 100644
--- a/lib/RT/Report.pm
+++ b/lib/RT/Report.pm
@@ -124,27 +124,7 @@ our %GROUPINGS_META = (
 
             my @res;
             if ( $args->{key} =~ /^CustomRole/ ) {
-                my $queues = $args->{'Queues'};
-                if ( !$queues && $args->{'Query'} ) {
-                    require RT::Interface::Web::QueryBuilder::Tree;
-                    my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-                    $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
-                    $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
-                }
-                return () unless $queues;
-
-                my $crs = RT::CustomRoles->new( $self->CurrentUser );
-                $crs->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
-                # Adding this to avoid returning all records when no queues are available.
-                $crs->LimitToObjectId(0);
-
-                for my $id ( keys %$queues ) {
-                    my $queue = RT::Queue->new( $self->CurrentUser );
-                    $queue->Load($id);
-                    next unless $queue->id;
-
-                    $crs->LimitToObjectId( $queue->id );
-                }
+                my $crs = $self->GetCustomRoles(%$args);
                 while ( my $cr = $crs->Next ) {
                     for my $field ( @{ $fields{ $cr->MaxValues ? 'user' : 'principal' } } ) {
                         push @res, [ $cr->Name, $field ], "CustomRole.{" . $cr->id . "}.$field";
@@ -256,28 +236,8 @@ our %GROUPINGS_META = (
             my $self = shift;
             my $args = shift;
 
-
-            my $queues = $args->{'Queues'};
-            if ( !$queues && $args->{'Query'} ) {
-                require RT::Interface::Web::QueryBuilder::Tree;
-                my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-                $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
-                $queues = $args->{'Queues'} = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
-            }
-            return () unless $queues;
-
             my @res;
-
-            my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
-            $CustomFields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
-            $CustomFields->LimitToObjectId(0);
-            foreach my $id (keys %$queues) {
-                my $queue = RT::Queue->new( $self->CurrentUser );
-                $queue->Load($id);
-                next unless $queue->id;
-                $CustomFields->SetContextObject( $queue ) if keys %$queues == 1;
-                $CustomFields->LimitToObjectId($queue->id);
-            }
+            my $CustomFields = $self->GetCustomFields(%$args);
             while ( my $CustomField = $CustomFields->Next ) {
                 push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
             }
@@ -1336,23 +1296,7 @@ sub _SetupCustomDateRanges {
 sub _NumericCustomFields {
     my $self         = shift;
     my %args         = @_;
-    my $custom_fields = RT::CustomFields->new( $self->CurrentUser );
-    $custom_fields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
-    $custom_fields->LimitToObjectId(0);
-
-    if ( $args{'Query'} ) {
-        require RT::Interface::Web::QueryBuilder::Tree;
-        my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-        $tree->ParseSQL( Query => $args{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
-        my $queues = $tree->GetReferencedQueues( CurrentUser => $self->CurrentUser );
-        foreach my $id ( keys %$queues ) {
-            my $queue = RT::Queue->new( $self->CurrentUser );
-            $queue->Load($id);
-            next unless $queue->id;
-            $custom_fields->SetContextObject($queue) if keys %$queues == 1;
-            $custom_fields->LimitToObjectId( $queue->id );
-        }
-    }
+    my $custom_fields = $self->GetCustomFields(%args);
 
     my @items;
     while ( my $custom_field = $custom_fields->Next ) {
@@ -1454,6 +1398,85 @@ sub _SingularClass {
     return (ref $self || $self) . '::Entry';
 }
 
+=head2 GetReferencedObjects Query => QUERY
+
+This is generally an abstraction of GetReferenced... methods in
+L<RT::Interface::Web::QueryBuilder::Tree>, based on what current report is for.
+
+Returns a tuple of the class and referenced objects.
+
+=cut
+
+sub GetReferencedObjects {
+    my $self = shift;
+    my %args = @_;
+
+    my $class  = 'RT::Queue';
+    my $method = 'GetReferencedQueues';
+
+    my $objects;
+    if ( $args{Query} ) {
+        require RT::Interface::Web::QueryBuilder::Tree;
+        my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
+        $tree->ParseSQL( Query => $args{'Query'}, CurrentUser => $self->CurrentUser, Class => ref $self );
+        $objects = $tree->$method( CurrentUser => $self->CurrentUser );
+    }
+    return ( $class, $objects );
+}
+
+=head2 GetCustomFields Query => QUERY
+
+Returns an L<RT::CustomFields> object that contains all possible custom
+fields the given query can refer to.
+
+=cut
+
+sub GetCustomFields {
+    my $self = shift;
+    my %args = @_;
+
+    my $custom_fields = RT::CustomFields->new( $self->CurrentUser );
+    $custom_fields->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
+    $custom_fields->LimitToObjectId(0);
+
+    if ( $args{'Query'} ) {
+        my ( $referenced_class, $referenced_objects ) = $self->GetReferencedObjects(%args);
+        foreach my $id ( keys %{$referenced_objects} ) {
+            my $object = $referenced_class->new( $self->CurrentUser );
+            $object->Load($id);
+            next unless $object->id;
+            $custom_fields->SetContextObject($object) if keys %{$referenced_objects} == 1;
+            $custom_fields->LimitToObjectId( $object->id );
+        }
+    }
+    return $custom_fields;
+}
+
+=head2 GetCustomRoles Query => QUERY
+
+Returns an L<RT::CustomRoles> object that contains all possible custom
+roles the given query can refer to.
+
+=cut
+
+sub GetCustomRoles {
+    my $self = shift;
+    my %args = @_;
+
+    my $custom_roles = RT::CustomRoles->new( $self->CurrentUser );
+    $custom_roles->LimitToLookupType( $self->RecordClass->CustomFieldLookupType );
+    # Adding this to avoid returning all records when no queues are available.
+    $custom_roles->LimitToObjectId(0);
+
+    my ( $referenced_class, $referenced_objects ) = $self->GetReferencedObjects(%args);
+    foreach my $id ( keys %{$referenced_objects} ) {
+        my $object = $referenced_class->new( $self->CurrentUser );
+        $object->Load($id);
+        next unless $object->id;
+        $custom_roles->LimitToObjectId( $object->id );
+    }
+    return $custom_roles;
+}
 
 RT::Base->_ImportOverlays();
 

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


hooks/post-receive
-- 
rt


More information about the rt-commit mailing list