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