Master to 4.2.8
[usit-rt.git] / lib / RT / Report / Tickets.pm
CommitLineData
84fb5b46
MKG
1# BEGIN BPS TAGGED BLOCK {{{
2#
3# COPYRIGHT:
4#
320f0092 5# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
84fb5b46
MKG
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
49package RT::Report::Tickets;
50
51use base qw/RT::Tickets/;
52use RT::Report::Tickets::Entry;
53
54use strict;
55use warnings;
56
af59614d
MKG
57use Scalar::Util qw(weaken);
58
59our @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);
83our %GROUPINGS;
84
85our %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
c33a4027 124 )], # loc_qw
af59614d
MKG
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
320f0092 186 return 'Custom field [_1]', $cf;
af59614d
MKG
187 },
188 },
189 Enum => {
190 Localize => 1,
191 },
192);
193
320f0092
MKG
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'
af59614d
MKG
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
244our @STATISTICS = (
245 COUNT => ['Ticket count', 'Count', 'id'],
246);
247
248foreach 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
260foreach 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
278our %STATISTICS;
279
280our %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
84fb5b46
MKG
359sub Groupings {
360 my $self = shift;
361 my %args = (@_);
84fb5b46 362
af59614d 363 my @fields;
84fb5b46 364
af59614d
MKG
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;
84fb5b46 370 }
af59614d
MKG
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 );
84fb5b46 376 }
af59614d
MKG
377 else {
378 $RT::Logger->error(
379 "$type has unsupported SubFields."
380 ." Not an array, a method name or a code reference"
381 );
84fb5b46
MKG
382 }
383 }
384 return @fields;
385}
386
af59614d 387sub IsValidGrouping {
84fb5b46 388 my $self = shift;
af59614d
MKG
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;
84fb5b46
MKG
410}
411
af59614d 412sub Statistics {
84fb5b46 413 my $self = shift;
af59614d
MKG
414 return map { ref($_)? $_->[0] : $_ } @STATISTICS;
415}
84fb5b46 416
af59614d
MKG
417sub Label {
418 my $self = shift;
419 my $column = shift;
84fb5b46 420
af59614d
MKG
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 }
84fb5b46 432
af59614d
MKG
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 );
84fb5b46
MKG
441}
442
af59614d 443sub ColumnInfo {
84fb5b46 444 my $self = shift;
af59614d 445 my $column = shift;
84fb5b46 446
af59614d
MKG
447 return $self->{'column_info'}{$column};
448}
84fb5b46 449
af59614d
MKG
450sub ColumnsList {
451 my $self = shift;
452 return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} }
453 keys %{ $self->{'column_info'} || {} };
84fb5b46
MKG
454}
455
af59614d 456sub SetupGroupings {
84fb5b46 457 my $self = shift;
af59614d
MKG
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;
84fb5b46 474
af59614d
MKG
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'};
84fb5b46
MKG
501 }
502
af59614d
MKG
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;
84fb5b46
MKG
552}
553
554=head2 _DoSearch
555
556Subclass _DoSearch from our parent so we can go through and add in empty
557columns if it makes sense
558
559=cut
560
561sub _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 {
af59614d 570 $self->PostProcessRecords;
84fb5b46
MKG
571 }
572}
573
574=head2 _FieldToFunction FIELD
575
576Returns a tuple of the field or a database function to allow grouping on that
577field.
578
579=cut
580
581sub _FieldToFunction {
582 my $self = shift;
583 my %args = (@_);
584
af59614d 585 $args{'FIELD'} ||= $args{'KEY'};
84fb5b46 586
af59614d
MKG
587 my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'KEY'} } };
588 return ('FUNCTION' => 'NULL') unless $meta;
84fb5b46 589
af59614d 590 return %args unless $meta->{'Function'};
84fb5b46 591
af59614d
MKG
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.
601sub Next {
602 my $self = shift;
603 $self->RT::SearchBuilder::Next(@_);
604
605}
606
607sub 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.
617sub _RoleGroupClass { "RT::Ticket" }
618sub _SingularClass { "RT::Report::Tickets::Entry" }
619
620sub SortEntries {
621 my $self = shift;
84fb5b46 622
af59614d
MKG
623 $self->_DoSearch if $self->{'must_redo_search'};
624 return unless $self->{'items'} && @{ $self->{'items'} };
84fb5b46 625
af59614d
MKG
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;
84fb5b46 637 }
af59614d
MKG
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
c33a4027
MKG
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
af59614d
MKG
665 if ( $order eq 'label' ) {
666 push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
667 $method = 'LabelValue';
84fb5b46 668 }
af59614d 669 elsif ( $order eq 'numeric label' ) {
c33a4027
MKG
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] ) };
af59614d 677 $method = 'LabelValue';
84fb5b46 678 }
af59614d
MKG
679 elsif ( $order eq 'raw' ) {
680 push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
681 $method = 'RawValue';
84fb5b46 682 }
af59614d
MKG
683 elsif ( $order eq 'numeric raw' ) {
684 push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
685 $method = 'RawValue';
84fb5b46 686 } else {
af59614d
MKG
687 $RT::Logger->error("Unknown sorting function '$order'");
688 next;
84fb5b46 689 }
af59614d
MKG
690 $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data;
691 }
692 $self->{'items'} = [
693 map $_->[0],
694 sort $by_multiple @data
695 ];
696}
697
698sub 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
713sub 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
737sub 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'} };
c33a4027
MKG
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.
af59614d
MKG
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'} };
84fb5b46 755 }
af59614d
MKG
756 $item->{'fetched'}{ lc $to } = 1;
757 }
758}
759
760sub 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';
84fb5b46 770 }
af59614d
MKG
771
772 $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
773 Type => $args{'SUBKEY'},
774 Field => $self->NotSetDateToNullFunction,
775 Timezone => $tz,
776 );
84fb5b46
MKG
777 return %args;
778}
779
af59614d
MKG
780sub GenerateCustomFieldFunction {
781 my $self = shift;
782 my %args = @_;
84fb5b46 783
af59614d
MKG
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
797sub GenerateUserFunction {
84fb5b46 798 my $self = shift;
af59614d
MKG
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;
84fb5b46
MKG
812}
813
af59614d
MKG
814sub GenerateWatcherFunction {
815 my $self = shift;
816 my %args = @_;
84fb5b46 817
af59614d
MKG
818 my $type = $args{'FIELD'};
819 $type = '' if $type eq 'Watcher';
84fb5b46 820
af59614d 821 my $column = $args{'SUBKEY'} || 'Name';
84fb5b46 822
af59614d
MKG
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
834sub DurationAsString {
84fb5b46 835 my $self = shift;
af59614d
MKG
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 }
84fb5b46 844
af59614d
MKG
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;
84fb5b46
MKG
853}
854
af59614d 855sub LabelValueCode {
84fb5b46 856 my $self = shift;
af59614d
MKG
857 my $name = shift;
858
859 my $display = $self->ColumnInfo( $name )->{'META'}{'Display'};
860 return undef unless $display;
861 return $self->FindImplementationCode( $display );
84fb5b46
MKG
862}
863
864
af59614d
MKG
865sub FindImplementationCode {
866 my $self = shift;
867 my $value = shift;
868 my $silent = shift;
84fb5b46 869
af59614d
MKG
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}
84fb5b46 894
af59614d
MKG
895sub 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}
84fb5b46 909
af59614d 910sub Deserialize {
84fb5b46 911 my $self = shift;
af59614d 912 my $data = shift;
84fb5b46 913
af59614d
MKG
914 $self->CleanSlate;
915 %$self = (%$self, %$data);
84fb5b46 916
af59614d
MKG
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
929sub 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 }
84fb5b46
MKG
962 }
963 }
af59614d
MKG
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;
84fb5b46
MKG
1067}
1068
1069RT::Base->_ImportOverlays();
1070
10711;