rt 4.2.14 (#13852)
[freeside.git] / rt / lib / RT / Report / Tickets.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2017 Best Practical Solutions, LLC
6 #                                          <sales@bestpractical.com>
7 #
8 # (Except where explicitly superseded by other copyright notices)
9 #
10 #
11 # LICENSE:
12 #
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17 #
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28 #
29 #
30 # CONTRIBUTION SUBMISSION POLICY:
31 #
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37 #
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46 #
47 # END BPS TAGGED BLOCK }}}
48
49 package RT::Report::Tickets;
50
51 use base qw/RT::Tickets/;
52 use RT::Report::Tickets::Entry;
53
54 use strict;
55 use warnings;
56
57 use Scalar::Util qw(weaken);
58
59 our @GROUPINGS = (
60     Status => 'Enum',                   #loc_left_pair
61
62     Queue  => 'Queue',                  #loc_left_pair
63
64     InitialPriority => 'Priority',          #loc_left_pair
65     FinalPriority   => 'Priority',          #loc_left_pair
66     Priority        => 'Priority',          #loc_left_pair
67
68     Owner         => 'User',            #loc_left_pair
69     Creator       => 'User',            #loc_left_pair
70     LastUpdatedBy => 'User',            #loc_left_pair
71
72     Requestor     => 'Watcher',         #loc_left_pair
73     Cc            => 'Watcher',         #loc_left_pair
74     AdminCc       => 'Watcher',         #loc_left_pair
75     Watcher       => 'Watcher',         #loc_left_pair
76
77     Created       => 'Date',            #loc_left_pair
78     Starts        => 'Date',            #loc_left_pair
79     Started       => 'Date',            #loc_left_pair
80     Resolved      => 'Date',            #loc_left_pair
81     Due           => 'Date',            #loc_left_pair
82     Told          => 'Date',            #loc_left_pair
83     LastUpdated   => 'Date',            #loc_left_pair
84
85     CF            => 'CustomField',     #loc_left_pair
86 );
87 our %GROUPINGS;
88
89 our %GROUPINGS_META = (
90     Queue => {
91         Display => sub {
92             my $self = shift;
93             my %args = (@_);
94
95             my $queue = RT::Queue->new( $self->CurrentUser );
96             $queue->Load( $args{'VALUE'} );
97             return $queue->Name;
98         },
99         Localize => 1,
100     },
101     Priority => {
102         Sort => 'numeric raw',
103     },
104     User => {
105         SubFields => [grep RT::User->_Accessible($_, "public"), qw(
106             Name RealName NickName
107             EmailAddress
108             Organization
109             Lang City Country Timezone
110         )],
111         Function => 'GenerateUserFunction',
112     },
113     Watcher => {
114         SubFields => [grep RT::User->_Accessible($_, "public"), qw(
115             Name RealName NickName
116             EmailAddress
117             Organization
118             Lang City Country Timezone
119         )],
120         Function => 'GenerateWatcherFunction',
121     },
122     Date => {
123         SubFields => [qw(
124             Time
125             Hourly Hour
126             Date Daily
127             DayOfWeek Day DayOfMonth DayOfYear
128             Month Monthly
129             Year Annually
130             WeekOfYear
131         )],  # loc_qw
132         Function => 'GenerateDateFunction',
133         Display => sub {
134             my $self = shift;
135             my %args = (@_);
136
137             my $raw = $args{'VALUE'};
138             return $raw unless defined $raw;
139
140             if ( $args{'SUBKEY'} eq 'DayOfWeek' ) {
141                 return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]);
142             }
143             elsif ( $args{'SUBKEY'} eq 'Month' ) {
144                 return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]);
145             }
146             return $raw;
147         },
148         Sort => 'raw',
149     },
150     CustomField => {
151         SubFields => sub {
152             my $self = shift;
153             my $args = shift;
154
155
156             my $queues = $args->{'Queues'};
157             if ( !$queues && $args->{'Query'} ) {
158                 require RT::Interface::Web::QueryBuilder::Tree;
159                 my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
160                 $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
161                 $queues = $args->{'Queues'} = $tree->GetReferencedQueues;
162             }
163             return () unless $queues;
164
165             my @res;
166
167             my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
168             foreach my $id (keys %$queues) {
169                 my $queue = RT::Queue->new( $self->CurrentUser );
170                 $queue->Load($id);
171                 next unless $queue->id;
172
173                 $CustomFields->LimitToQueue($queue->id);
174             }
175             $CustomFields->LimitToGlobal;
176             while ( my $CustomField = $CustomFields->Next ) {
177                 push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
178             }
179             return @res;
180         },
181         Function => 'GenerateCustomFieldFunction',
182         Label => sub {
183             my $self = shift;
184             my %args = (@_);
185
186             my ($cf) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
187             if ( $cf =~ /^\d+$/ ) {
188                 my $obj = RT::CustomField->new( $self->CurrentUser );
189                 $obj->Load( $cf );
190                 $cf = $obj->Name;
191             }
192
193             return 'Custom field [_1]', $cf;
194         },
195     },
196     Enum => {
197         Localize => 1,
198     },
199 );
200
201 # loc'able strings below generated with (s/loq/loc/):
202 #   perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loq("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
203 #
204 # loc("Ticket count")
205 # loc("Summary of time worked")
206 # loc("Total time worked")
207 # loc("Average time worked")
208 # loc("Minimum time worked")
209 # loc("Maximum time worked")
210 # loc("Summary of time estimated")
211 # loc("Total time estimated")
212 # loc("Average time estimated")
213 # loc("Minimum time estimated")
214 # loc("Maximum time estimated")
215 # loc("Summary of time left")
216 # loc("Total time left")
217 # loc("Average time left")
218 # loc("Minimum time left")
219 # loc("Maximum time left")
220 # loc("Summary of Created-Started")
221 # loc("Total Created-Started")
222 # loc("Average Created-Started")
223 # loc("Minimum Created-Started")
224 # loc("Maximum Created-Started")
225 # loc("Summary of Created-Resolved")
226 # loc("Total Created-Resolved")
227 # loc("Average Created-Resolved")
228 # loc("Minimum Created-Resolved")
229 # loc("Maximum Created-Resolved")
230 # loc("Summary of Created-LastUpdated")
231 # loc("Total Created-LastUpdated")
232 # loc("Average Created-LastUpdated")
233 # loc("Minimum Created-LastUpdated")
234 # loc("Maximum Created-LastUpdated")
235 # loc("Summary of Starts-Started")
236 # loc("Total Starts-Started")
237 # loc("Average Starts-Started")
238 # loc("Minimum Starts-Started")
239 # loc("Maximum Starts-Started")
240 # loc("Summary of Due-Resolved")
241 # loc("Total Due-Resolved")
242 # loc("Average Due-Resolved")
243 # loc("Minimum Due-Resolved")
244 # loc("Maximum Due-Resolved")
245 # loc("Summary of Started-Resolved")
246 # loc("Total Started-Resolved")
247 # loc("Average Started-Resolved")
248 # loc("Minimum Started-Resolved")
249 # loc("Maximum Started-Resolved")
250
251 our @STATISTICS = (
252     COUNT => ['Ticket count', 'Count', 'id'],
253 );
254
255 foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
256     my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
257     push @STATISTICS, (
258         "ALL($field)" => ["Summary of $friendly",   'TimeAll',     $field ],
259         "SUM($field)" => ["Total $friendly",   'Time', 'SUM', $field ],
260         "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
261         "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
262         "MAX($field)" => ["Maximum $friendly", 'Time', 'MAX', $field ],
263     );
264 }
265
266
267 foreach my $pair (qw(
268     Created-Started
269     Created-Resolved
270     Created-LastUpdated
271     Starts-Started
272     Due-Resolved
273     Started-Resolved
274 )) {
275     my ($from, $to) = split /-/, $pair;
276     push @STATISTICS, (
277         "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ],
278         "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ],
279         "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
280         "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
281         "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],
282     );
283 }
284
285 our %STATISTICS;
286
287 our %STATISTICS_META = (
288     Count => {
289         Function => sub {
290             my $self = shift;
291             my $field = shift || 'id';
292
293             return (
294                 FUNCTION => 'COUNT',
295                 FIELD    => 'id'
296             );
297         },
298     },
299     Simple => {
300         Function => sub {
301             my $self = shift;
302             my ($function, $field) = @_;
303             return (FUNCTION => $function, FIELD => $field);
304         },
305     },
306     Time => {
307         Function => sub {
308             my $self = shift;
309             my ($function, $field) = @_;
310             return (FUNCTION => "$function(?)*60", FIELD => $field);
311         },
312         Display => 'DurationAsString',
313     },
314     TimeAll => {
315         SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
316         Function => sub {
317             my $self = shift;
318             my $field = shift;
319             return (
320                 Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field },
321                 Average => { FUNCTION => "AVG(?)*60", FIELD => $field },
322                 Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field },
323                 Total   => { FUNCTION => "SUM(?)*60", FIELD => $field },
324             );
325         },
326         Display => 'DurationAsString',
327     },
328     DateTimeInterval => {
329         Function => sub {
330             my $self = shift;
331             my ($function, $from, $to) = @_;
332
333             my $interval = $self->_Handle->DateTimeIntervalFunction(
334                 From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
335                 To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
336             );
337
338             return (FUNCTION => "$function($interval)");
339         },
340         Display => 'DurationAsString',
341     },
342     DateTimeIntervalAll => {
343         SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
344         Function => sub {
345             my $self = shift;
346             my ($from, $to) = @_;
347
348             my $interval = $self->_Handle->DateTimeIntervalFunction(
349                 From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
350                 To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
351             );
352
353             return (
354                 Minimum => { FUNCTION => "MIN($interval)" },
355                 Average => { FUNCTION => "AVG($interval)" },
356                 Maximum => { FUNCTION => "MAX($interval)" },
357                 Total   => { FUNCTION => "SUM($interval)" },
358             );
359         },
360         Display => 'DurationAsString',
361     },
362 );
363
364 sub Groupings {
365     my $self = shift;
366     my %args = (@_);
367
368     my @fields;
369
370     my @tmp = @GROUPINGS;
371     while ( my ($field, $type) = splice @tmp, 0, 2 ) {
372         my $meta = $GROUPINGS_META{ $type } || {};
373         unless ( $meta->{'SubFields'} ) {
374             push @fields, [$field, $field], $field;
375         }
376         elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
377             push @fields, map { ([$field, $_], "$field.$_") } @{ $meta->{'SubFields'} };
378         }
379         elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) {
380             push @fields, $code->( $self, \%args );
381         }
382         else {
383             $RT::Logger->error(
384                 "$type has unsupported SubFields."
385                 ." Not an array, a method name or a code reference"
386             );
387         }
388     }
389     return @fields;
390 }
391
392 sub IsValidGrouping {
393     my $self = shift;
394     my %args = (@_);
395     return 0 unless $args{'GroupBy'};
396
397     my ($key, $subkey) = split /\./, $args{'GroupBy'}, 2;
398
399     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
400     my $type = $GROUPINGS{$key};
401     return 0 unless $type;
402     return 1 unless $subkey;
403
404     my $meta = $GROUPINGS_META{ $type } || {};
405     unless ( $meta->{'SubFields'} ) {
406         return 0;
407     }
408     elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
409         return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} };
410     }
411     elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'}, 'silent' ) ) {
412         return 1 if grep $_ eq "$key.$subkey", $code->( $self, \%args );
413     }
414     return 0;
415 }
416
417 sub Statistics {
418     my $self = shift;
419     return map { ref($_)? $_->[0] : $_ } @STATISTICS;
420 }
421
422 sub Label {
423     my $self = shift;
424     my $column = shift;
425
426     my $info = $self->ColumnInfo( $column );
427     unless ( $info ) {
428         $RT::Logger->error("Unknown column '$column'");
429         return $self->CurrentUser->loc('(Incorrect data)');
430     }
431
432     if ( $info->{'META'}{'Label'} ) {
433         my $code = $self->FindImplementationCode( $info->{'META'}{'Label'} );
434         return $self->CurrentUser->loc( $code->( $self, %$info ) )
435             if $code;
436     }
437
438     my $res = '';
439     if ( $info->{'TYPE'} eq 'statistic' ) {
440         $res = $info->{'INFO'}[0];
441     }
442     else {
443         $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'};
444     }
445     return $self->CurrentUser->loc( $res );
446 }
447
448 sub ColumnInfo {
449     my $self = shift;
450     my $column = shift;
451
452     return $self->{'column_info'}{$column};
453 }
454
455 sub ColumnsList {
456     my $self = shift;
457     return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} }
458         keys %{ $self->{'column_info'} || {} };
459 }
460
461 sub SetupGroupings {
462     my $self = shift;
463     my %args = (
464         Query => undef,
465         GroupBy => undef,
466         Function => undef,
467         @_
468     );
469
470     $self->FromSQL( $args{'Query'} ) if $args{'Query'};
471
472     # Apply ACL checks
473     $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
474
475     # See if our query is distinct
476     if (not $self->{'joins_are_distinct'} and $self->_isJoined) {
477         # If it isn't, we need to do this in two stages -- first, find
478         # the distinct matching tickets (with no group by), then search
479         # within the matching tickets grouped by what is wanted.
480         my @match = (0);
481         $self->Columns( 'id' );
482         while (my $row = $self->Next) {
483             push @match, $row->id;
484         }
485
486         # Replace the query with one that matches precisely those
487         # tickets, with no joins.  We then mark it as having been ACL'd,
488         # since it was by dint of being in the search results above
489         $self->CleanSlate;
490         while ( @match > 1000 ) {
491             my @batch = splice( @match, 0, 1000 );
492             $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@batch );
493         }
494         $self->Limit( FIELD => 'Id', OPERATOR => 'IN', VALUE => \@match );
495         $self->{'_sql_current_user_can_see_applied'} = 1
496     }
497
498
499     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
500
501     my $i = 0;
502
503     my @group_by = grep defined && length,
504         ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
505     @group_by = ('Status') unless @group_by;
506
507     foreach my $e ( splice @group_by ) {
508         unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) {
509             RT->Logger->error("'$e' is not a valid grouping for reports; skipping");
510             next;
511         }
512         my ($key, $subkey) = split /\./, $e, 2;
513         $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
514         $e->{'TYPE'} = 'grouping';
515         $e->{'INFO'} = $GROUPINGS{ $key };
516         $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
517         $e->{'POSITION'} = $i++;
518         push @group_by, $e;
519     }
520     $self->GroupBy( map { {
521         ALIAS    => $_->{'ALIAS'},
522         FIELD    => $_->{'FIELD'},
523         FUNCTION => $_->{'FUNCTION'},
524     } } @group_by );
525
526     my %res = (Groups => [], Functions => []);
527     my %column_info;
528
529     foreach my $group_by ( @group_by ) {
530         $group_by->{'NAME'} = $self->Column( %$group_by );
531         $column_info{ $group_by->{'NAME'} } = $group_by;
532         push @{ $res{'Groups'} }, $group_by->{'NAME'};
533     }
534
535     %STATISTICS = @STATISTICS unless keys %STATISTICS;
536
537     my @function = grep defined && length,
538         ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
539     push @function, 'COUNT' unless @function;
540     foreach my $e ( @function ) {
541         $e = {
542             TYPE => 'statistic',
543             KEY  => $e,
544             INFO => $STATISTICS{ $e },
545             META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
546             POSITION => $i++,
547         };
548         unless ( $e->{'INFO'} && $e->{'META'} ) {
549             $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report");
550             $e->{'FUNCTION'} = 'NULL';
551             $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
552         }
553         elsif ( $e->{'META'}{'Function'} ) {
554             my $code = $self->FindImplementationCode( $e->{'META'}{'Function'} );
555             unless ( $code ) {
556                 $e->{'FUNCTION'} = 'NULL';
557                 $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
558             }
559             elsif ( $e->{'META'}{'SubValues'} ) {
560                 my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
561                 $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
562                 while ( my ($k, $v) = each %tmp ) {
563                     $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v );
564                     @{ $e->{'MAP'}{ $k } }{'FUNCTION', 'ALIAS', 'FIELD'} =
565                         @{ $v }{'FUNCTION', 'ALIAS', 'FIELD'};
566                 }
567             }
568             else {
569                 my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
570                 $e->{'NAME'} = $self->Column( %tmp );
571                 @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'};
572             }
573         }
574         elsif ( $e->{'META'}{'Calculate'} ) {
575             $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
576         }
577         push @{ $res{'Functions'} }, $e->{'NAME'};
578         $column_info{ $e->{'NAME'} } = $e;
579     }
580
581     $self->{'column_info'} = \%column_info;
582
583     return %res;
584 }
585
586 =head2 _DoSearch
587
588 Subclass _DoSearch from our parent so we can go through and add in empty 
589 columns if it makes sense 
590
591 =cut
592
593 sub _DoSearch {
594     my $self = shift;
595     $self->SUPER::_DoSearch( @_ );
596     if ( $self->{'must_redo_search'} ) {
597         $RT::Logger->crit(
598 "_DoSearch is not so successful as it still needs redo search, won't call AddEmptyRows"
599         );
600     }
601     else {
602         $self->PostProcessRecords;
603     }
604 }
605
606 =head2 _FieldToFunction FIELD
607
608 Returns a tuple of the field or a database function to allow grouping on that 
609 field.
610
611 =cut
612
613 sub _FieldToFunction {
614     my $self = shift;
615     my %args = (@_);
616
617     $args{'FIELD'} ||= $args{'KEY'};
618
619     my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'KEY'} } };
620     return ('FUNCTION' => 'NULL') unless $meta;
621
622     return %args unless $meta->{'Function'};
623
624     my $code = $self->FindImplementationCode( $meta->{'Function'} );
625     return ('FUNCTION' => 'NULL') unless $code;
626
627     return $code->( $self, %args );
628 }
629
630
631 # Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we 
632 # don't want.
633 sub Next {
634     my $self = shift;
635     $self->RT::SearchBuilder::Next(@_);
636
637 }
638
639 sub NewItem {
640     my $self = shift;
641     my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
642     $res->{'report'} = $self;
643     weaken $res->{'report'};
644     return $res;
645 }
646
647 # This is necessary since normally NewItem (above) is used to intuit the
648 # correct class.  However, since we're abusing a subclass, it's incorrect.
649 sub _RoleGroupClass { "RT::Ticket" }
650 sub _SingularClass { "RT::Report::Tickets::Entry" }
651
652 sub SortEntries {
653     my $self = shift;
654
655     $self->_DoSearch if $self->{'must_redo_search'};
656     return unless $self->{'items'} && @{ $self->{'items'} };
657
658     my @groups =
659         grep $_->{'TYPE'} eq 'grouping',
660         map $self->ColumnInfo($_),
661         $self->ColumnsList;
662     return unless @groups;
663
664     my @SORT_OPS;
665     my $by_multiple = sub ($$) {
666         for my $f ( @SORT_OPS ) {
667             my $r = $f->($_[0], $_[1]);
668             return $r if $r;
669         }
670     };
671     my @data = map [$_], @{ $self->{'items'} };
672
673     for ( my $i = 0; $i < @groups; $i++ ) {
674         my $group_by = $groups[$i];
675         my $idx = $i+1;
676
677         my $order = $group_by->{'META'}{Sort} || 'label';
678         my $method = $order =~ /label$/ ? 'LabelValue' : 'RawValue';
679
680         unless ($order =~ /^numeric/) {
681             # Traverse the values being used for labels.
682             # If they all look like numbers or undef, flag for a numeric sort.
683             my $looks_like_number = 1;
684             foreach my $item (@data){
685                 my $label = $item->[0]->$method($group_by->{'NAME'});
686
687                 $looks_like_number = 0
688                     unless (not defined $label)
689                     or Scalar::Util::looks_like_number( $label );
690             }
691             $order = "numeric $order" if $looks_like_number;
692         }
693
694         if ( $order eq 'label' ) {
695             push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
696             $method = 'LabelValue';
697         }
698         elsif ( $order eq 'numeric label' ) {
699             my $nv = $self->loc("(no value)");
700             # Sort the (no value) elements first, by comparing for them
701             # first, and falling back to a numeric sort on all other
702             # values.
703             push @SORT_OPS, sub {
704                 (($_[0][$idx] ne $nv) <=> ($_[1][$idx] ne $nv))
705              || ( $_[0][$idx]         <=>  $_[1][$idx]        ) };
706             $method = 'LabelValue';
707         }
708         elsif ( $order eq 'raw' ) {
709             push @SORT_OPS, sub { ($_[0][$idx]//'') cmp ($_[1][$idx]//'') };
710             $method = 'RawValue';
711         }
712         elsif ( $order eq 'numeric raw' ) {
713             push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
714             $method = 'RawValue';
715         } else {
716             $RT::Logger->error("Unknown sorting function '$order'");
717             next;
718         }
719         $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data;
720     }
721     $self->{'items'} = [
722         map $_->[0],
723         sort $by_multiple @data
724     ];
725 }
726
727 sub PostProcessRecords {
728     my $self = shift;
729
730     my $info = $self->{'column_info'};
731     foreach my $column ( values %$info ) {
732         next unless $column->{'TYPE'} eq 'statistic';
733         if ( $column->{'META'}{'Calculate'} ) {
734             $self->CalculatePostFunction( $column );
735         }
736         elsif ( $column->{'META'}{'SubValues'} ) {
737             $self->MapSubValues( $column );
738         }
739     }
740 }
741
742 sub CalculatePostFunction {
743     my $self = shift;
744     my $info = shift;
745
746     my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} );
747     unless ( $code ) {
748         # TODO: fill in undefs
749         return;
750     }
751
752     my $column = $info->{'NAME'};
753
754     my $base_query = $self->Query;
755     foreach my $item ( @{ $self->{'items'} } ) {
756         $item->{'values'}{ lc $column } = $code->(
757             $self,
758             Query => join(
759                 ' AND ', map "($_)", grep defined && length, $base_query, $item->Query,
760             ),
761         );
762         $item->{'fetched'}{ lc $column } = 1;
763     }
764 }
765
766 sub MapSubValues {
767     my $self = shift;
768     my $info = shift;
769
770     my $to = $info->{'NAME'};
771     my $map = $info->{'MAP'};
772
773     foreach my $item ( @{ $self->{'items'} } ) {
774         my $dst = $item->{'values'}{ lc $to } = { };
775         while (my ($k, $v) = each %{ $map } ) {
776             $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} };
777             # This mirrors the logic in RT::Record::__Value When that
778             # ceases tp use the UTF-8 flag as a character/byte
779             # distinction from the database, this can as well.
780             utf8::decode( $dst->{ $k } )
781                 if defined $dst->{ $k }
782                and not utf8::is_utf8( $dst->{ $k } );
783             delete $item->{'fetched'}{ lc $v->{'NAME'} };
784         }
785         $item->{'fetched'}{ lc $to } = 1;
786     }
787 }
788
789 sub GenerateDateFunction {
790     my $self = shift;
791     my %args = @_;
792
793     my $tz;
794     if ( RT->Config->Get('ChartsTimezonesInDB') ) {
795         my $to = $self->CurrentUser->UserObj->Timezone
796             || RT->Config->Get('Timezone');
797         $tz = { From => 'UTC', To => $to }
798             if $to && lc $to ne 'utc';
799     }
800
801     $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
802         Type     => $args{'SUBKEY'},
803         Field    => $self->NotSetDateToNullFunction,
804         Timezone => $tz,
805     );
806     return %args;
807 }
808
809 sub GenerateCustomFieldFunction {
810     my $self = shift;
811     my %args = @_;
812
813     my ($name) = ( $args{'SUBKEY'} =~ /^\{(.*)\}$/ );
814     my $cf = RT::CustomField->new( $self->CurrentUser );
815     $cf->Load($name);
816     unless ( $cf->id ) {
817         $RT::Logger->error("Couldn't load CustomField #$name");
818         @args{qw(FUNCTION FIELD)} = ('NULL', undef);
819     } else {
820         my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf);
821         @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content');
822     }
823     return %args;
824 }
825
826 sub GenerateUserFunction {
827     my $self = shift;
828     my %args = @_;
829
830     my $column = $args{'SUBKEY'} || 'Name';
831     my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"}
832         ||= $self->Join(
833             TYPE   => 'LEFT',
834             ALIAS1 => 'main',
835             FIELD1 => $args{'FIELD'},
836             TABLE2 => 'Users',
837             FIELD2 => 'id',
838         );
839     @args{qw(ALIAS FIELD)} = ($u_alias, $column);
840     return %args;
841 }
842
843 sub GenerateWatcherFunction {
844     my $self = shift;
845     my %args = @_;
846
847     my $type = $args{'FIELD'};
848     $type = '' if $type eq 'Watcher';
849
850     my $column = $args{'SUBKEY'} || 'Name';
851
852     my $u_alias = $self->{"_sql_report_watcher_users_alias_$type"};
853     unless ( $u_alias ) {
854         my ($g_alias, $gm_alias);
855         ($g_alias, $gm_alias, $u_alias) = $self->_WatcherJoin( Name => $type );
856         $self->{"_sql_report_watcher_users_alias_$type"} = $u_alias;
857     }
858     @args{qw(ALIAS FIELD)} = ($u_alias, $column);
859
860     return %args;
861 }
862
863 sub DurationAsString {
864     my $self = shift;
865     my %args = @_;
866     my $v = $args{'VALUE'};
867     unless ( ref $v ) {
868         return $self->loc("(no value)") unless defined $v && length $v;
869         return RT::Date->new( $self->CurrentUser )->DurationAsString(
870             $v, Show => 3, Short => 1
871         );
872     }
873
874     my $date = RT::Date->new( $self->CurrentUser );
875     my %res = %$v;
876     foreach my $e ( values %res ) {
877         $e = $date->DurationAsString( $e, Short => 1, Show => 3 )
878             if defined $e && length $e;
879         $e = $self->loc("(no value)") unless defined $e && length $e;
880     }
881     return \%res;
882 }
883
884 sub LabelValueCode {
885     my $self = shift;
886     my $name = shift;
887
888     my $display = $self->ColumnInfo( $name )->{'META'}{'Display'};
889     return undef unless $display;
890     return $self->FindImplementationCode( $display );
891 }
892
893
894 sub FindImplementationCode {
895     my $self = shift;
896     my $value = shift;
897     my $silent = shift;
898
899     my $code;
900     unless ( $value ) {
901         $RT::Logger->error("Value is not defined. Should be method name or code reference")
902             unless $silent;
903         return undef;
904     }
905     elsif ( !ref $value ) {
906         $code = $self->can( $value );
907         unless ( $code ) {
908             $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" )
909                 unless $silent;
910             return undef;
911         }
912     }
913     elsif ( ref( $value ) eq 'CODE' ) {
914         $code = $value;
915     }
916     else {
917         $RT::Logger->error("$value is not method name or code reference")
918             unless $silent;
919         return undef;
920     }
921     return $code;
922 }
923
924 sub Serialize {
925     my $self = shift;
926
927     my %clone = %$self;
928 # current user, handle and column_info
929     delete @clone{'user', 'DBIxHandle', 'column_info'};
930     $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ];
931     $clone{'column_info'} = {};
932     while ( my ($k, $v) = each %{ $self->{'column_info'} } ) {
933         $clone{'column_info'}{$k} = { %$v };
934         delete $clone{'column_info'}{$k}{'META'};
935     }
936     return \%clone;
937 }
938
939 sub Deserialize {
940     my $self = shift;
941     my $data = shift;
942
943     $self->CleanSlate;
944     %$self = (%$self, %$data);
945
946     $self->{'items'} = [
947         map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r }
948         @{ $self->{'items'} }
949     ];
950     foreach my $e ( values %{ $self->{column_info} } ) {
951         $e->{'META'} = $e->{'TYPE'} eq 'grouping'
952             ? $GROUPINGS_META{ $e->{'INFO'} }
953             : $STATISTICS_META{ $e->{'INFO'}[1] }
954     }
955 }
956
957
958 sub FormatTable {
959     my $self = shift;
960     my %columns = @_;
961
962     my (@head, @body, @footer);
963
964     @head = ({ cells => []});
965     foreach my $column ( @{ $columns{'Groups'} } ) {
966         push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) };
967     }
968
969     my $i = 0;
970     while ( my $entry = $self->Next ) {
971         $body[ $i ] = { even => ($i+1)%2, cells => [] };
972         $i++;
973     }
974     @footer = ({ even => ++$i%2, cells => []});
975
976     my $g = 0;
977     foreach my $column ( @{ $columns{'Groups'} } ) {
978         $i = 0;
979         my $last;
980         while ( my $entry = $self->Next ) {
981             my $value = $entry->LabelValue( $column );
982             if ( !$last || $last->{'value'} ne $value ) {
983                 push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value };
984                 $last->{even} = $g++ % 2
985                     unless $column eq $columns{'Groups'}[-1];
986             }
987             else {
988                 $i++;
989                 $last->{rowspan} = ($last->{rowspan}||1) + 1;
990             }
991         }
992     }
993     push @{ $footer[0]{'cells'} }, {
994         type => 'label',
995         value => $self->loc('Total'),
996         colspan => scalar @{ $columns{'Groups'} },
997     };
998
999     my $pick_color = do {
1000         my @colors = RT->Config->Get("ChartColors");
1001         sub { $colors[ $_[0] % @colors - 1 ] }
1002     };
1003
1004     my $function_count = 0;
1005     foreach my $column ( @{ $columns{'Functions'} } ) {
1006         $i = 0;
1007
1008         my $info = $self->ColumnInfo( $column );
1009
1010         my @subs = ('');
1011         if ( $info->{'META'}{'SubValues'} ) {
1012             @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->(
1013                 $self
1014             );
1015         }
1016
1017         my %total;
1018         unless ( $info->{'META'}{'NoTotals'} ) {
1019             while ( my $entry = $self->Next ) {
1020                 my $raw = $entry->RawValue( $column ) || {};
1021                 $raw = { '' => $raw } unless ref $raw;
1022                 $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
1023             }
1024             @subs = grep $total{$_}, @subs
1025                 unless $info->{'META'}{'NoHideEmpty'};
1026         }
1027
1028         my $label = $self->Label( $column );
1029
1030         unless (@subs) {
1031             while ( my $entry = $self->Next ) {
1032                 push @{ $body[ $i++ ]{'cells'} }, {
1033                     type => 'value',
1034                     value => undef,
1035                     query => $entry->Query,
1036                 };
1037             }
1038             push @{ $head[0]{'cells'} }, {
1039                 type => 'head',
1040                 value => $label,
1041                 rowspan => scalar @head,
1042                 color => $pick_color->(++$function_count),
1043             };
1044             push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
1045             next;
1046         }
1047
1048         if ( @subs > 1 && @head == 1 ) {
1049             $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} };
1050         }
1051
1052         if ( @subs == 1 ) {
1053             push @{ $head[0]{'cells'} }, {
1054                 type => 'head',
1055                 value => $label,
1056                 rowspan => scalar @head,
1057                 color => $pick_color->(++$function_count),
1058             };
1059         } else {
1060             push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs };
1061             push @{ $head[1]{'cells'} }, { type => 'head', value => $_, color => $pick_color->(++$function_count) }
1062                 foreach @subs;
1063         }
1064
1065         while ( my $entry = $self->Next ) {
1066             my $query = $entry->Query;
1067             my $value = $entry->LabelValue( $column ) || {};
1068             $value = { '' => $value } unless ref $value;
1069             foreach my $e ( @subs ) {
1070                 push @{ $body[ $i ]{'cells'} }, {
1071                     type => 'value',
1072                     value => $value->{ $e },
1073                     query => $query,
1074                 };
1075             }
1076             $i++;
1077         }
1078
1079         unless ( $info->{'META'}{'NoTotals'} ) {
1080             my $total_code = $self->LabelValueCode( $column );
1081             foreach my $e ( @subs ) {
1082                 my $total = $total{ $e };
1083                 $total = $total_code->( $self, %$info, VALUE => $total )
1084                     if $total_code;
1085                 push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
1086             }
1087         }
1088         else {
1089             foreach my $e ( @subs ) {
1090                 push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
1091             }
1092         }
1093     }
1094
1095     return thead => \@head, tbody => \@body, tfoot => \@footer;
1096 }
1097
1098 RT::Base->_ImportOverlays();
1099
1100 1;