]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/reporter/clark-kent.pl
much more premacrud
[Evergreen.git] / Open-ILS / src / reporter / clark-kent.pl
1 #!/usr/bin/perl
2 # vim:ts=4:noet:
3
4 use strict;
5 use DBI;
6 use FileHandle;
7 use XML::LibXML;
8 use Getopt::Long;
9 use DateTime;
10 use DateTime::Format::ISO8601;
11 use Data::Dumper;
12 use Text::CSV_XS;
13 use Spreadsheet::WriteExcel::Big;
14 use OpenSRF::EX qw/:try/;
15 use OpenSRF::Utils qw/:daemon/;
16 use OpenSRF::Utils::JSON;
17 #use OpenSRF::Utils::Logger qw/:level/;
18 use OpenSRF::System;
19 use OpenSRF::AppSession;
20 use OpenSRF::Utils::SettingsClient;
21 use OpenILS::Reporter::SQLBuilder;
22 use POSIX;
23 use GD::Graph::pie;
24 use GD::Graph::bars3d;
25 use GD::Graph::lines3d;
26 use Tie::IxHash;
27 use Email::Send;
28
29 use open ':utf8';
30
31
32 my ($count, $config, $sleep_interval, $lockfile, $daemon) = (1, '/openils/conf/opensrf_core.xml', 10, '/tmp/reporter-LOCK');
33
34 GetOptions(
35         "daemon"        => \$daemon,
36         "sleep=i"       => \$sleep_interval,
37         "concurrency=i" => \$count,
38         "boostrap=s"    => \$config,
39         "lockfile=s"    => \$lockfile,
40 );
41
42 if (-e $lockfile) {
43         die "I seem to be running already. If not, remove $lockfile and try again\n";
44 }
45
46 OpenSRF::System->bootstrap_client( config_file => $config );
47
48 # XXX Get this stuff from the settings server
49 my $sc = OpenSRF::Utils::SettingsClient->new;
50 my $db_driver = $sc->config_value( reporter => setup => database => 'driver' );
51 my $db_host = $sc->config_value( reporter => setup => database => 'host' );
52 my $db_port = $sc->config_value( reporter => setup => database => 'port' );
53 my $db_name = $sc->config_value( reporter => setup => database => 'db' );
54 if (!$db_name) {
55     $db_name = $sc->config_value( reporter => setup => database => 'name' );
56     print STDERR "WARN: <database><name> is a deprecated setting for database name. For future compatibility, you should use <database><db> instead." if $db_name; 
57 }
58 my $db_user = $sc->config_value( reporter => setup => database => 'user' );
59 my $db_pw = $sc->config_value( reporter => setup => database => 'pw' );
60
61 die "Unable to retrieve database connection information from the settings server" unless ($db_driver && $db_host && $db_port && $db_name && $db_user);
62
63 my $email_server = $sc->config_value( email_notify => 'smtp_server' );
64 my $email_sender = $sc->config_value( email_notify => 'sender_address' );
65 my $success_template = $sc->config_value( reporter => setup => files => 'success_template' );
66 my $fail_template = $sc->config_value( reporter => setup => files => 'fail_template' );
67
68 my $output_base = $sc->config_value( reporter => setup => files => 'output_base' );
69
70 my $base_uri = $sc->config_value( reporter => setup => 'base_uri' );
71
72 my $dsn = "dbi:" . $db_driver . ":dbname=" . $db_name .';host=' . $db_host . ';port=' . $db_port;
73
74 my ($dbh,$running,$sth,@reports,$run, $current_time);
75
76 if ($daemon) {
77         open(F, ">$lockfile");
78         print F $$;
79         close F;
80         daemonize("Clark Kent, waiting for trouble");
81 }
82
83
84 DAEMON:
85
86 $dbh = DBI->connect($dsn,$db_user,$db_pw, {AutoCommit => 1, pg_enable_utf8 => 1, RaiseError => 1});
87
88 $current_time = DateTime->from_epoch( epoch => time() )->strftime('%FT%T%z');
89
90 # make sure we're not already running $count reports
91 ($running) = $dbh->selectrow_array(<<SQL);
92 SELECT  count(*)
93   FROM  reporter.schedule
94   WHERE start_time IS NOT NULL AND complete_time IS NULL;
95 SQL
96
97 if ($count <= $running) {
98         if ($daemon) {
99                 $dbh->disconnect;
100                 sleep 1;
101                 POSIX::waitpid( -1, POSIX::WNOHANG );
102                 sleep $sleep_interval;
103                 goto DAEMON;
104         }
105         print "Already running maximum ($running) concurrent reports\n";
106         exit 1;
107 }
108
109 # if we have some open slots then generate the sql
110 $run = $count - $running;
111
112 $sth = $dbh->prepare(<<SQL);
113 SELECT  *
114   FROM  reporter.schedule
115   WHERE start_time IS NULL AND run_time < NOW()
116   ORDER BY run_time
117   LIMIT $run;
118 SQL
119
120 $sth->execute;
121
122 @reports = ();
123 while (my $r = $sth->fetchrow_hashref) {
124         my $s3 = $dbh->selectrow_hashref(<<"    SQL", {}, $r->{report});
125                 SELECT * FROM reporter.report WHERE id = ?;
126         SQL
127
128         my $s2 = $dbh->selectrow_hashref(<<"    SQL", {}, $s3->{template});
129                 SELECT * FROM reporter.template WHERE id = ?;
130         SQL
131
132         $s3->{template} = $s2;
133         $r->{report} = $s3;
134
135         my $b = OpenILS::Reporter::SQLBuilder->new;
136         $b->register_params( OpenSRF::Utils::JSON->JSON2perl( $r->{report}->{data} ) );
137
138         $r->{resultset} = $b->parse_report( OpenSRF::Utils::JSON->JSON2perl( $r->{report}->{template}->{data} ) );
139         $r->{resultset}->relative_time($r->{run_time});
140         push @reports, $r;
141 }
142
143 $sth->finish;
144
145 $dbh->disconnect;
146
147 # Now we spawn the report runners
148
149 for my $r ( @reports ) {
150         next if (safe_fork());
151
152         # This is the child (runner) process;
153         daemonize("Clark Kent reporting: $r->{report}->{name}");
154
155         $dbh = DBI->connect($dsn,$db_user,$db_pw, {AutoCommit => 1, pg_enable_utf8 => 1, RaiseError => 1});
156
157         try {
158                 $dbh->do(<<'            SQL',{}, $r->{id});
159                         UPDATE  reporter.schedule
160                           SET   start_time = now()
161                           WHERE id = ?;
162                 SQL
163
164                 $sth = $dbh->prepare($r->{resultset}->toSQL);
165
166                 $sth->execute;
167                 $r->{data} = $sth->fetchall_arrayref;
168
169                 $r->{column_labels} = [$r->{resultset}->column_label_list];
170
171                 if ($r->{resultset}->pivot_data && $r->{resultset}->pivot_label) {
172                         my @labels = $r->{resultset}->column_label_list;
173                         my $newdata = pivot_data(
174                                 { columns => $r->{column_labels}, data => $r->{data}},
175                                 $r->{resultset}->pivot_label,
176                                 $r->{resultset}->pivot_data,
177                                 $r->{resultset}->pivot_default
178                         );
179
180                         $r->{column_labels} = $newdata->{columns};
181                         $r->{data} = $newdata->{data};
182                         $r->{group_by_list} = $newdata->{group_by_list};
183                 } else {
184                         $r->{group_by_list} = [$r->{resultset}->group_by_list(0)];
185                 }
186
187                 my $s2 = $r->{report}->{template}->{id};
188                 my $s3 = $r->{report}->{id};
189                 my $output = $r->{id};
190
191                 mkdir($output_base);
192                 mkdir("$output_base/$s2");
193                 mkdir("$output_base/$s2/$s3");
194                 mkdir("$output_base/$s2/$s3/$output");
195         
196                 my $output_dir = "$output_base/$s2/$s3/$output";
197
198                 if ( $r->{csv_format} ) {
199                         build_csv("$output_dir/report-data.csv", $r);
200                 }
201
202                 if ( $r->{excel_format} ) {
203                         build_excel("$output_dir/report-data.xls", $r);
204                 }
205
206                 build_html("$output_dir/report-data.html", $r);
207
208                 $dbh->begin_work;
209
210                 if ($r->{report}->{recur} ) {
211                         my $sql = <<'                   SQL';
212                                 INSERT INTO reporter.schedule (
213                                                 report,
214                                                 folder,
215                                                 runner,
216                                                 run_time,
217                                                 email,
218                                                 csv_format,
219                                                 excel_format,
220                                                 html_format,
221                                                 chart_pie,
222                                                 chart_bar,
223                                                 chart_line )
224                                         VALUES ( ?, ?, ?, ?::TIMESTAMPTZ + ?, ?, ?, ?, ?, ?, ?, ? );
225                         SQL
226
227                         $dbh->do(
228                                 $sql,
229                                 {},
230                                 $r->{report}->{id},
231                                 $r->{folder},
232                                 $r->{runner},
233                                 $r->{run_time},
234                                 $r->{report}->{recurance},
235                                 $r->{email},
236                                 $r->{csv_format},
237                                 $r->{excel_format},
238                                 $r->{html_format},
239                                 $r->{chart_pie},
240                                 $r->{chart_bar},
241                                 $r->{chart_line},
242                         );
243                 }
244
245                 $dbh->do(<<'            SQL',{}, $r->{id});
246                         UPDATE  reporter.schedule
247                           SET   complete_time = now()
248                           WHERE id = ?;
249                 SQL
250
251                 $dbh->commit;
252
253                 my $new_r = $dbh->selectrow_hashref(<<"         SQL", {}, $r->{id});
254                         SELECT * FROM reporter.schedule WHERE id = ?;
255                 SQL
256
257                 $r->{start_time} = $new_r->{start_time};
258                 $r->{complete_time} = $new_r->{complete_time};
259
260                 if ($r->{email}) {
261                         send_success($r);
262                 }
263
264         } otherwise {
265                 my $e = shift;
266                 $r->{error_text} = ''.$e;
267                 if (!$dbh->{AutoCommit}) {
268                         $dbh->rollback;
269                 }
270                 $dbh->do(<<'            SQL',{}, $e, $r->{id});
271                         UPDATE  reporter.schedule
272                           SET   error_text = ?,
273                                 complete_time = now(),
274                                 error_code = 1
275                           WHERE id = ?;
276                 SQL
277
278                 my $new_r = $dbh->selectrow_hashref(<<"         SQL", {}, $r->{id});
279                         SELECT * FROM reporter.schedule WHERE id = ?;
280                 SQL
281
282                 $r->{error_text} = $new_r->{error_text};
283                 $r->{complete_time} = $new_r->{complete_time};
284
285                 if ($r->{email}) {
286                         send_fail($r);
287                 }
288
289         };
290
291         $dbh->disconnect;
292
293         exit; # leave the child
294 }
295
296 if ($daemon) {
297         sleep 1;
298         POSIX::waitpid( -1, POSIX::WNOHANG );
299         sleep $sleep_interval;
300         goto DAEMON;
301 }
302
303 #-------------------------------------------------------------------
304
305 sub send_success {
306         my $r = shift;
307         open F, $success_template;
308         my $tmpl = join('',<F>);
309         close F;
310
311         my $url = $base_uri . '/' .
312                 $r->{report}->{template}->{id} . '/' .
313                 $r->{report}->{id} . '/' .
314                 $r->{id} . '/report-data.html';
315
316         $tmpl =~ s/{TO}/$r->{email}/smog;
317         $tmpl =~ s/{FROM}/$email_sender/smog;
318         $tmpl =~ s/{REPLY_TO}/$email_sender/smog;
319         $tmpl =~ s/{REPORT_NAME}/$r->{report}->{template}->{name} -- $r->{report}->{name}/smog;
320         $tmpl =~ s/{RUN_TIME}/$r->{run_time}/smog;
321         $tmpl =~ s/{COMPLETE_TIME}/$r->{complete_time}/smog;
322         $tmpl =~ s/{OUTPUT_URL}/$url/smog;
323
324         my $sender = Email::Send->new({mailer => 'SMTP'});
325         $sender->mailer_args([Host => $email_server]);
326         $sender->send($tmpl);
327 }
328
329 sub send_fail {
330         my $r = shift;
331         open F, $fail_template;
332         my $tmpl = join('',<F>);
333         close F;
334
335         my $sql = $r->{resultset}->toSQL;
336
337         $tmpl =~ s/{TO}/$r->{email}/smog;
338         $tmpl =~ s/{FROM}/$email_sender/smog;
339         $tmpl =~ s/{REPLY_TO}/$email_sender/smog;
340         $tmpl =~ s/{REPORT_NAME}/$r->{report}->{template}->{name} -- $r->{report}->{name}/smog;
341         $tmpl =~ s/{RUN_TIME}/$r->{run_time}/smog;
342         $tmpl =~ s/{ERROR_TEXT}/$r->{error_text}/smog;
343         $tmpl =~ s/{SQL}/$sql/smog;
344
345         my $sender = Email::Send->new({mailer => 'SMTP'});
346         $sender->mailer_args([Host => $email_server]);
347         $sender->send($tmpl);
348 }
349
350 sub build_csv {
351         my $file = shift;
352         my $r = shift;
353
354         my $csv = Text::CSV_XS->new({ always_quote => 1, eol => "\015\012" });
355
356         return unless ($csv);
357         
358         my $f = new FileHandle (">$file");
359
360         $csv->print($f, $r->{column_labels});
361         $csv->print($f, $_) for (@{$r->{data}});
362
363         $f->close;
364 }
365 sub build_excel {
366         my $file = shift;
367         my $r = shift;
368         my $xls = Spreadsheet::WriteExcel::Big->new($file);
369
370         my $sheetname = substr($r->{report}->{name},1,31);
371         $sheetname =~ s/\W/_/gos;
372         
373         my $sheet = $xls->add_worksheet($sheetname);
374
375         $sheet->write_row('A1', $r->{column_labels});
376
377         $sheet->write_col('A2', $r->{data});
378
379         $xls->close;
380 }
381
382 sub build_html {
383         my $file = shift;
384         my $r = shift;
385
386         my $index = new FileHandle (">$file");
387         
388         # index header
389         print $index <<"        HEADER";
390 <html>
391         <head>
392                 <title>$$r{report}{name}</title>
393                 <style>
394                         table { border-collapse: collapse; }
395                         th { background-color: lightgray; }
396                         td,th { border: solid black 1px; }
397                         * { font-family: sans-serif; font-size: 10px; }
398                 </style>
399         </head>
400         <body>
401                 <center>
402                 <h2><u>$$r{report}{name}</u></h2>
403                 $$r{report}{description}<br/><br/><br/>
404         HEADER
405
406         my @links;
407
408         # add a link to the raw output html
409         push @links, "<a href='report-data.html.raw.html'>Tabular Output</a>" if ($r->{html_format});
410
411         # add a link to the CSV output
412         push @links, "<a href='report-data.xls'>Excel Output</a>" if ($r->{excel_format});
413
414         # add a link to the CSV output
415         push @links, "<a href='report-data.csv'>CSV Output</a>" if ($r->{csv_format});
416
417         print $index join(' -- ', @links);
418         print $index "<br/><br/><br/><br/></center>";
419
420         if ($r->{html_format}) {
421                 # create the raw output html file
422                 my $raw = new FileHandle (">$file.raw.html");
423                 print $raw "<html><head><title>$$r{report}{name}</title>";
424
425                 print $raw <<'          CSS';
426                         <style>
427                                 table { border-collapse: collapse; }
428                                 th { background-color: lightgray; }
429                                 td,th { border: solid black 1px; }
430                                 * { font-family: sans-serif; font-size: 10px; }
431                         </style>
432                 CSS
433
434                 print $raw "</head><body><table>";
435
436                 {       no warnings;
437                         print $raw "<tr><th>".join('</th><th>',@{$r->{column_labels}}).'</th></tr>';
438                         print $raw "<tr><td>".join('</td><td>',@$_                   ).'</td></tr>' for (@{$r->{data}});
439                 }
440
441                 print $raw '</table></body></html>';
442         
443                 $raw->close;
444         }
445
446         # Time for a pie chart
447         if ($r->{chart_pie}) {
448                 my $pics = draw_pie($r, $file);
449                 for my $pic (@$pics) {
450                         print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/><br/><br/><br/><br/>";
451                 }
452         }
453
454         print $index '<br/><br/><br/><br/>';
455         # Time for a bar chart
456         if ($r->{chart_bar}) {
457                 my $pics = draw_bars($r, $file);
458                 for my $pic (@$pics) {
459                         print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/><br/><br/><br/><br/>";
460                 }
461         }
462
463         print $index '<br/><br/><br/><br/>';
464         # Time for a bar chart
465         if ($r->{chart_line}) {
466                 my $pics = draw_lines($r, $file);
467                 for my $pic (@$pics) {
468                         print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/><br/><br/><br/><br/>";
469                 }
470         }
471
472         # and that's it!
473         print $index '</body></html>';
474         
475         $index->close;
476 }
477
478 sub draw_pie {
479         my $r = shift;
480         my $file = shift;
481
482         my $data = $r->{data};
483
484         my @groups = @{ $r->{group_by_list} };
485         
486         my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
487         delete @values[@groups];
488
489         #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
490         
491         my @pics;
492         for my $vcol (@values) {
493                 next unless (defined $vcol);
494
495                 my @pic_data = ([],[]);
496                 for my $row (@$data) {
497                         next if (!defined($$row[$vcol]) || $$row[$vcol] == 0);
498                         my $val = $$row[$vcol];
499                         push @{$pic_data[0]}, join(" -- ", @$row[@groups])." ($val)";
500                         push @{$pic_data[1]}, $val;
501                 }
502
503                 next unless (@{$pic_data[0]});
504
505                 my $size = 300;
506                 my $split = int(scalar(@{$pic_data[0]}) / $size);
507                 my $last = scalar(@{$pic_data[0]}) % $size;
508
509                 for my $sub_graph (0 .. $split) {
510                         
511                         if ($sub_graph == $split) {
512                                 $size = $last;
513                         }
514
515                         my @sub_data;
516                         for my $set (@pic_data) {
517                                 push @sub_data, [ splice(@$set,0,$size) ];
518                         }
519
520                         my $pic = new GD::Graph::pie;
521
522                         $pic->set(
523                                 label                   => $r->{column_labels}->[$vcol],
524                                 start_angle             => 180,
525                                 legend_placement        => 'R',
526                                 #logo                   => $logo,
527                                 #logo_position          => 'TL',
528                                 #logo_resize            => 0.5,
529                                 show_values             => 1,
530                         );
531
532                         my $format = $pic->export_format;
533
534                         open(IMG, ">$file.pie.$vcol.$sub_graph.$format");
535                         binmode IMG;
536
537                         my $forgetit = 0;
538                         try {
539                                 $pic->plot(\@sub_data) or die $pic->error;
540                                 print IMG $pic->gd->$format;
541                         } otherwise {
542                                 my $e = shift;
543                                 warn "Couldn't draw $file.pie.$vcol.$sub_graph.$format : $e";
544                                 $forgetit = 1;
545                         };
546
547                         close IMG;
548
549
550                         push @pics,
551                                 { file => "pie.$vcol.$sub_graph.$format",
552                                   name => $r->{column_labels}->[$vcol].' (Pie)',
553                                 } unless ($forgetit);
554
555                         last if ($sub_graph == $split);
556                 }
557
558         }
559         
560         return \@pics;
561 }
562
563 sub draw_bars {
564         my $r = shift;
565         my $file = shift;
566         my $data = $r->{data};
567
568         #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
569
570         my @groups = @{ $r->{group_by_list} };
571
572         
573         my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
574         splice(@values,$_,1) for (reverse @groups);
575
576         my @pic_data;
577         {       no warnings;
578                 for my $row (@$data) {
579                         push @{$pic_data[0]}, join(' -- ', @$row[@groups]);
580                 }
581         }
582
583         my @leg;
584         my $set = 1;
585
586         my %trim_candidates;
587
588         my $max_y = 0;
589         for my $vcol (@values) {
590                 next unless (defined $vcol);
591                 $pic_data[$set] ||= [];
592
593                 my $pos = 0;
594                 for my $row (@$data) {
595                         my $val = $$row[$vcol] ? $$row[$vcol] : 0;
596                         push @{$pic_data[$set]}, $val;
597                         $max_y = $val if ($val > $max_y);
598                         $trim_candidates{$pos}++ if ($val == 0);
599                         $pos++;
600                 }
601
602                 $set++;
603         }
604         my $set_count = scalar(@pic_data) - 1;
605         my @trim_cols = grep { $trim_candidates{$_} == $set_count } keys %trim_candidates;
606
607         my @new_data;
608         my @use_me;
609         my @no_use;
610         my $set_index = 0;
611         for my $dataset (@pic_data) {
612                 splice(@$dataset,$_,1) for (reverse sort @trim_cols);
613
614                 if (grep { $_ } @$dataset) {
615                         push @new_data, $dataset;
616                         push @use_me, $set_index if ($set_index > 0);
617                 } else {
618                         push @no_use, $set_index;
619                 }
620                 $set_index++;
621                 
622         }
623
624         return [] unless ($new_data[0] && @{$new_data[0]});
625
626         for my $col (@use_me) {
627                 push @leg, $r->{column_labels}->[$values[$col - 1]];
628         }
629
630         my $w = 100 + 10 * scalar(@{$new_data[0]});
631         $w = 400 if ($w < 400);
632
633         my $h = 10 * (scalar(@new_data) / 2);
634
635         $h = 0 if ($h < 0);
636
637         my $pic = new GD::Graph::bars3d ($w + 250, $h + 500);
638
639         $pic->set(
640                 title                   => $r->{report}{name},
641                 x_labels_vertical       => 1,
642                 shading                 => 1,
643                 bar_depth               => 5,
644                 bar_spacing             => 2,
645                 y_max_value             => $max_y,
646                 legend_placement        => 'TR',
647                 boxclr                  => 'lgray',
648                 #logo                   => $logo,
649                 #logo_position          => 'R',
650                 #logo_resize            => 0.5,
651                 show_values             => 1,
652                 overwrite               => 1,
653         );
654         $pic->set_legend(@leg);
655
656         my $format = $pic->export_format;
657
658         open(IMG, ">$file.bar.$format");
659         binmode IMG;
660
661         try {
662                 $pic->plot(\@new_data) or die $pic->error;
663                 print IMG $pic->gd->$format;
664         } otherwise {
665                 my $e = shift;
666                 warn "Couldn't draw $file.bar.$format : $e";
667         };
668
669         close IMG;
670
671         return [{ file => "bar.$format",
672                   name => $r->{report}{name}.' (Bar)',
673                 }];
674
675 }
676
677 sub draw_lines {
678         my $r = shift;
679         my $file = shift;
680         my $data = $r->{data};
681
682         #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
683
684         my @groups = @{ $r->{group_by_list} };
685         
686         my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
687         splice(@values,$_,1) for (reverse @groups);
688
689         my @pic_data;
690         {       no warnings;
691                 for my $row (@$data) {
692                         push @{$pic_data[0]}, join(' -- ', @$row[@groups]);
693                 }
694         }
695
696         my @leg;
697         my $set = 1;
698
699         my $max_y = 0;
700         for my $vcol (@values) {
701                 next unless (defined $vcol);
702                 $pic_data[$set] ||= [];
703
704
705                 for my $row (@$data) {
706                         my $val = $$row[$vcol] ? $$row[$vcol] : 0;
707                         push @{$pic_data[$set]}, $val;
708                         $max_y = $val if ($val > $max_y);
709                 }
710
711                 $set++;
712         }
713         my $set_count = scalar(@pic_data) - 1;
714
715         my @new_data;
716         my @use_me;
717         my @no_use;
718         my $set_index = 0;
719         for my $dataset (@pic_data) {
720
721                 if (grep { $_ } @$dataset) {
722                         push @new_data, $dataset;
723                         push @use_me, $set_index if ($set_index > 0);
724                 } else {
725                         push @no_use, $set_index;
726                 }
727                 $set_index++;
728                 
729         }
730
731         for my $col (@use_me) {
732                 push @leg, $r->{column_labels}->[$values[$col - 1]];
733         }
734
735         my $w = 100 + 10 * scalar(@{$new_data[0]});
736         $w = 400 if ($w < 400);
737
738         my $h = 10 * (scalar(@new_data) / 2);
739
740         $h = 0 if ($h < 0);
741
742         my $pic = new GD::Graph::lines3d ($w + 250, $h + 500);
743
744         $pic->set(
745                 title                   => $r->{report}{name},
746                 x_labels_vertical       => 1,
747                 shading                 => 1,
748                 line_depth              => 5,
749                 y_max_value             => $max_y,
750                 legend_placement        => 'TR',
751                 boxclr                  => 'lgray',
752                 #logo                   => $logo,
753                 #logo_position          => 'R',
754                 #logo_resize            => 0.5,
755                 show_values             => 1,
756                 overwrite               => 1,
757         );
758         $pic->set_legend(@leg);
759
760         my $format = $pic->export_format;
761
762         open(IMG, ">$file.line.$format");
763         binmode IMG;
764
765         try {
766                 $pic->plot(\@new_data) or die $pic->error;
767                 print IMG $pic->gd->$format;
768         } otherwise {
769                 my $e = shift;
770                 warn "Couldn't draw $file.line.$format : $e";
771         };
772
773         close IMG;
774
775         return [{ file => "line.$format",
776                   name => $r->{report}{name}.' (Bar)',
777                 }];
778
779 }
780
781
782 sub pivot_data {
783         my $blob = shift;
784         my $pivot_label = shift;
785         my $pivot_data = shift;
786         my $default = shift;
787         $default = 0 unless (defined $default);
788
789         my $data = $$blob{data};
790         my $cols = $$blob{columns};
791
792         my @keep_labels =  @$cols;
793         splice(@keep_labels, $_ - 1, 1) for (reverse sort ($pivot_label, $pivot_data));
794
795         my @keep_cols = (0 .. @$cols - 1);
796         splice(@keep_cols, $_ - 1, 1) for (reverse sort ($pivot_label, $pivot_data));
797
798         my @gb = ( 0 .. @keep_cols - 1);
799
800         #first, find the unique list of pivot values
801         my %tmp;
802         for my $row (@$data) {
803                 $tmp{ $$row[$pivot_label - 1] } = 1;
804         }
805         my @new_cols = sort keys %tmp;
806
807         tie my %split_data, 'Tie::IxHash';
808         for my $row (@$data) {
809
810                 my $row_fp = ''. join('', map { defined($$row[$_]) ? $$row[$_] : '' } @keep_cols);
811                 $split_data{$row_fp} ||= [];
812
813                 push @{ $split_data{$row_fp} }, $row;
814         }
815
816
817         #now loop over the data, building a new result set
818         tie my %new_data, 'Tie::IxHash';
819
820         for my $fp ( keys %split_data ) {
821
822                 $new_data{$fp} = [];
823
824                 for my $col (@keep_cols) {
825                         push @{ $new_data{$fp} }, $split_data{$fp}[0][$col];
826                 }
827
828                 for my $col (@new_cols) {
829
830                         my ($datum) = map { $_->[$pivot_data - 1] } grep { $_->[$pivot_label - 1] eq $col } @{ $split_data{$fp} };
831                         $datum ||= $default;
832                         push @{ $new_data{$fp} }, $datum;
833                 }
834         }
835
836         push @keep_labels, @new_cols;
837
838         return { columns => \@keep_labels, data => [ values %new_data ], group_by_list => \@gb };
839 }
840
841