10 use DateTime::Format::ISO8601;
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/$logger/;
19 use OpenSRF::AppSession;
20 use OpenSRF::Utils::SettingsClient;
21 use OpenILS::Reporter::SQLBuilder;
24 use GD::Graph::bars3d;
25 use GD::Graph::lines3d;
32 my ($count, $config, $sleep_interval, $lockfile, $daemon) = (1, 'SYSCONFDIR/opensrf_core.xml', 10, '/tmp/reporter-LOCK');
36 "sleep=i" => \$sleep_interval,
37 "concurrency=i" => \$count,
38 "bootstrap|boostrap=s" => \$config,
39 "lockfile=s" => \$lockfile,
43 die "I seem to be running already. If not, remove $lockfile and try again\n";
46 OpenSRF::System->bootstrap_client( config_file => $config );
48 my (%data_db, %state_db);
50 my $sc = OpenSRF::Utils::SettingsClient->new;
52 $data_db{db_driver} = $sc->config_value( reporter => setup => database => 'driver' );
53 $data_db{db_host} = $sc->config_value( reporter => setup => database => 'host' );
54 $data_db{db_port} = $sc->config_value( reporter => setup => database => 'port' );
55 $data_db{db_name} = $sc->config_value( reporter => setup => database => 'db' );
56 if (!$data_db{db_name}) {
57 $data_db{db_name} = $sc->config_value( reporter => setup => database => 'name' );
58 print STDERR "WARN: <database><name> is a deprecated setting for database name. For future compatibility, you should use <database><db> instead." if $data_db{db_name};
60 $data_db{db_user} = $sc->config_value( reporter => setup => database => 'user' );
61 $data_db{db_pw} = $sc->config_value( reporter => setup => database => 'pw' );
65 # Fetch the optional state database connection info
66 $state_db{db_driver} = $sc->config_value( reporter => setup => state_store => 'driver' ) || $data_db{db_driver};
67 $state_db{db_host} = $sc->config_value( reporter => setup => state_store => 'host' ) || $data_db{db_host};
68 $state_db{db_port} = $sc->config_value( reporter => setup => state_store => 'port' ) || $data_db{db_port};
69 $state_db{db_name} = $sc->config_value( reporter => setup => state_store => 'db' );
70 if (!$state_db{db_name}) {
71 $state_db{db_name} = $sc->config_value( reporter => setup => state_store => 'name' ) || $data_db{db_name};
73 $state_db{db_user} = $sc->config_value( reporter => setup => state_store => 'user' ) || $data_db{db_user};
74 $state_db{db_pw} = $sc->config_value( reporter => setup => state_store => 'pw' ) || $data_db{db_pw};
77 die "Unable to retrieve database connection information from the settings server"
78 unless ($state_db{db_driver} && $state_db{db_host} && $state_db{db_port} && $state_db{db_name} && $state_db{db_user} &&
79 $data_db{db_driver} && $data_db{db_host} && $data_db{db_port} && $data_db{db_name} && $data_db{db_user});
81 my $email_server = $sc->config_value( email_notify => 'smtp_server' );
82 my $email_sender = $sc->config_value( email_notify => 'sender_address' );
83 my $success_template = $sc->config_value( reporter => setup => files => 'success_template' );
84 my $fail_template = $sc->config_value( reporter => setup => files => 'fail_template' );
85 my $output_base = $sc->config_value( reporter => setup => files => 'output_base' );
86 my $base_uri = $sc->config_value( reporter => setup => 'base_uri' );
88 my $state_dsn = "dbi:" . $state_db{db_driver} . ":dbname=" . $state_db{db_name} .';host=' . $state_db{db_host} . ';port=' . $state_db{db_port};
89 my $data_dsn = "dbi:" . $data_db{db_driver} . ":dbname=" . $data_db{db_name} .';host=' . $data_db{db_host} . ';port=' . $data_db{db_port};
91 my ($dbh,$running,$sth,@reports,$run, $current_time);
94 open(F, ">$lockfile") or die "Cannot write lockfile '$lockfile'";
97 daemonize("Clark Kent, waiting for trouble");
108 pg_expand_array => 0,
114 $current_time = DateTime->from_epoch( epoch => time() )->strftime('%FT%T%z');
116 # make sure we're not already running $count reports
117 ($running) = $dbh->selectrow_array(<<SQL);
119 FROM reporter.schedule
120 WHERE start_time IS NOT NULL AND complete_time IS NULL;
123 if ($count <= $running) {
127 POSIX::waitpid( -1, POSIX::WNOHANG );
128 sleep $sleep_interval;
131 print "Already running maximum ($running) concurrent reports\n";
135 # if we have some open slots then generate the sql
136 $run = $count - $running;
138 $sth = $dbh->prepare(<<SQL);
140 FROM reporter.schedule
141 WHERE start_time IS NULL AND run_time < NOW()
149 while (my $r = $sth->fetchrow_hashref) {
150 my $s3 = $dbh->selectrow_hashref(<<" SQL", {}, $r->{report});
151 SELECT * FROM reporter.report WHERE id = ?;
154 my $s2 = $dbh->selectrow_hashref(<<" SQL", {}, $s3->{template});
155 SELECT * FROM reporter.template WHERE id = ?;
158 $s3->{template} = $s2;
161 my $b = OpenILS::Reporter::SQLBuilder->new;
162 my $report_data = OpenSRF::Utils::JSON->JSON2perl( $r->{report}->{data} );
163 $b->register_params( $report_data );
165 $r->{resultset} = $b->parse_report( OpenSRF::Utils::JSON->JSON2perl( $r->{report}->{template}->{data} ) );
166 $r->{resultset}->set_pivot_data($report_data->{__pivot_data}) if $report_data->{__pivot_data};
167 $r->{resultset}->set_pivot_label($report_data->{__pivot_label}) if $report_data->{__pivot_label};
168 $r->{resultset}->set_pivot_default($report_data->{__pivot_default}) if $report_data->{__pivot_default};
169 $r->{resultset}->relative_time($r->{run_time});
177 # Now we spawn the report runners
179 for my $r ( @reports ) {
180 next if (safe_fork());
182 # This is the child (runner) process;
183 daemonize("Clark Kent reporting: $r->{report}->{name}");
185 my $state_dbh = DBI->connect(
190 pg_expand_array => 0,
196 my $data_dbh = DBI->connect(
201 pg_expand_array => 0,
208 $state_dbh->do(<<' SQL',{}, $r->{id});
209 UPDATE reporter.schedule
210 SET start_time = now()
214 $logger->debug('Report SQL: ' . $r->{resultset}->toSQL);
215 $sth = $data_dbh->prepare($r->{resultset}->toSQL);
218 $r->{data} = $sth->fetchall_arrayref;
220 $r->{column_labels} = [$r->{resultset}->column_label_list];
222 if ($r->{resultset}->pivot_data && $r->{resultset}->pivot_label) {
223 my @labels = $r->{resultset}->column_label_list;
224 my $newdata = pivot_data(
225 { columns => $r->{column_labels}, data => $r->{data}},
226 $r->{resultset}->pivot_label,
227 $r->{resultset}->pivot_data,
228 $r->{resultset}->pivot_default
231 $r->{column_labels} = $newdata->{columns};
232 $r->{data} = $newdata->{data};
233 $r->{group_by_list} = $newdata->{group_by_list};
235 $r->{group_by_list} = [$r->{resultset}->group_by_list(0)];
238 my $s2 = $r->{report}->{template}->{id};
239 my $s3 = $r->{report}->{id};
240 my $output = $r->{id};
243 mkdir("$output_base/$s2");
244 mkdir("$output_base/$s2/$s3");
245 mkdir("$output_base/$s2/$s3/$output");
247 my $output_dir = "$output_base/$s2/$s3/$output";
249 if ( $r->{csv_format} ) {
250 build_csv("$output_dir/report-data.csv", $r);
253 if ( $r->{excel_format} ) {
254 build_excel("$output_dir/report-data.xls", $r);
257 build_html("$output_dir/report-data.html", $r);
259 $state_dbh->begin_work;
261 if ($r->{report}->{recur} ) {
263 INSERT INTO reporter.schedule (
275 VALUES ( ?, ?, ?, ?::TIMESTAMPTZ + ?, ?, ?, ?, ?, ?, ?, ? );
285 $r->{report}->{recurrence},
296 $state_dbh->do(<<' SQL',{}, $r->{id});
297 UPDATE reporter.schedule
298 SET complete_time = now()
304 my $new_r = $state_dbh->selectrow_hashref(<<" SQL", {}, $r->{id});
305 SELECT * FROM reporter.schedule WHERE id = ?;
308 $r->{start_time} = $new_r->{start_time};
309 $r->{complete_time} = $new_r->{complete_time};
317 $r->{error_text} = ''.$e;
318 if (!$state_dbh->{AutoCommit}) {
319 $state_dbh->rollback;
321 $state_dbh->do(<<' SQL',{}, $e, $r->{id});
322 UPDATE reporter.schedule
324 complete_time = now(),
329 my $new_r = $state_dbh->selectrow_hashref(<<" SQL", {}, $r->{id});
330 SELECT * FROM reporter.schedule WHERE id = ?;
333 $r->{error_text} = $new_r->{error_text};
334 $r->{complete_time} = $new_r->{complete_time};
342 $state_dbh->disconnect;
343 $data_dbh->disconnect;
345 exit; # leave the child
350 POSIX::waitpid( -1, POSIX::WNOHANG );
351 sleep $sleep_interval;
355 #-------------------------------------------------------------------
359 open F, $success_template or die "Cannot read '$success_template'";
360 my $tmpl = join('',<F>);
363 my $url = $base_uri . '/' .
364 $r->{report}->{template}->{id} . '/' .
365 $r->{report}->{id} . '/' .
366 $r->{id} . '/report-data.html';
368 $tmpl =~ s/{TO}/$r->{email}/smog;
369 $tmpl =~ s/{FROM}/$email_sender/smog;
370 $tmpl =~ s/{REPLY_TO}/$email_sender/smog;
371 $tmpl =~ s/{REPORT_NAME}/$r->{report}->{template}->{name} -- $r->{report}->{name}/smog;
372 $tmpl =~ s/{RUN_TIME}/$r->{run_time}/smog;
373 $tmpl =~ s/{COMPLETE_TIME}/$r->{complete_time}/smog;
374 $tmpl =~ s/{OUTPUT_URL}/$url/smog;
376 my $sender = Email::Send->new({mailer => 'SMTP'});
377 $sender->mailer_args([Host => $email_server]);
378 $sender->send($tmpl);
383 open F, $fail_template or die "Cannot read '$fail_template'";
384 my $tmpl = join('',<F>);
387 my $sql = $r->{resultset}->toSQL;
389 $tmpl =~ s/{TO}/$r->{email}/smog;
390 $tmpl =~ s/{FROM}/$email_sender/smog;
391 $tmpl =~ s/{REPLY_TO}/$email_sender/smog;
392 $tmpl =~ s/{REPORT_NAME}/$r->{report}->{template}->{name} -- $r->{report}->{name}/smog;
393 $tmpl =~ s/{RUN_TIME}/$r->{run_time}/smog;
394 $tmpl =~ s/{ERROR_TEXT}/$r->{error_text}/smog;
395 $tmpl =~ s/{SQL}/$sql/smog;
397 my $sender = Email::Send->new({mailer => 'SMTP'});
398 $sender->mailer_args([Host => $email_server]);
399 $sender->send($tmpl);
406 my $csv = Text::CSV_XS->new({ always_quote => 1, eol => "\015\012" });
408 return unless ($csv);
410 my $f = new FileHandle (">$file") or die "Cannot write to '$file'";
412 $csv->print($f, $r->{column_labels});
413 $csv->print($f, $_) for (@{$r->{data}});
420 my $xls = Spreadsheet::WriteExcel::Big->new($file);
422 my $sheetname = substr($r->{report}->{name},0,30);
423 $sheetname =~ s/\W/_/gos;
425 my $sheet = $xls->add_worksheet($sheetname);
427 $sheet->write_row('A1', $r->{column_labels});
429 $sheet->write_col('A2', $r->{data});
438 my $index = new FileHandle (">$file") or die "Cannot write to '$file'";
441 print $index <<" HEADER";
444 <title>$$r{report}{name}</title>
446 table { border-collapse: collapse; }
447 th { background-color: lightgray; }
448 td,th { border: solid black 1px; }
449 * { font-family: sans-serif; font-size: 10px; }
454 <h2><u>$$r{report}{name}</u></h2>
455 $$r{report}{description}<br/><br/><br/>
460 my $br4 = '<br/>' x 4;
461 # add a link to the raw output html
462 push @links, "<a href='report-data.html.raw.html'>Tabular Output</a>" if ($r->{html_format});
464 # add a link to the CSV output
465 push @links, "<a href='report-data.xls'>Excel Output</a>" if ($r->{excel_format});
467 # add a link to the CSV output
468 push @links, "<a href='report-data.csv'>CSV Output</a>" if ($r->{csv_format});
470 print $index join(' -- ', @links);
471 print $index "$br4</center>";
473 if ($r->{html_format}) {
474 # create the raw output html file
475 my $raw = new FileHandle (">$file.raw.html") or die "Cannot write to '$file.raw.html'";
476 print $raw "<html><head><title>$$r{report}{name}</title>";
480 table { border-collapse: collapse; }
481 th { background-color: lightgray; }
482 td,th { border: solid black 1px; }
483 * { font-family: sans-serif; font-size: 10px; }
487 print $raw "</head><body><table>";
490 print $raw "<tr><th>".join('</th><th>',@{$r->{column_labels}}).'</th></tr>';
491 print $raw "<tr><td>".join('</td><td>',@$_ ).'</td></tr>' for (@{$r->{data}});
494 print $raw '</table></body></html>';
499 # Time for a pie chart
500 if ($r->{chart_pie}) {
501 my $pics = draw_pie($r, $file);
502 for my $pic (@$pics) {
503 print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/>$br4";
508 # Time for a bar chart
509 if ($r->{chart_bar}) {
510 my $pics = draw_bars($r, $file);
511 for my $pic (@$pics) {
512 print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/>$br4";
517 # Time for a bar chart
518 if ($r->{chart_line}) {
519 my $pics = draw_lines($r, $file);
520 for my $pic (@$pics) {
521 print $index "<img src='report-data.html.$pic->{file}' alt='$pic->{name}'/>$br4";
526 print $index '</body></html>';
535 my $data = $r->{data};
537 my @groups = @{ $r->{group_by_list} };
539 my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
540 delete @values[@groups];
542 #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
545 for my $vcol (@values) {
546 next unless (defined $vcol);
548 my @pic_data = ([],[]);
549 for my $row (@$data) {
550 next if (!defined($$row[$vcol]) || $$row[$vcol] == 0);
551 my $val = $$row[$vcol];
552 push @{$pic_data[0]}, join(" -- ", @$row[@groups])." ($val)";
553 push @{$pic_data[1]}, $val;
556 next unless (@{$pic_data[0]});
559 my $split = int(scalar(@{$pic_data[0]}) / $size);
560 my $last = scalar(@{$pic_data[0]}) % $size;
562 for my $sub_graph (0 .. $split) {
564 if ($sub_graph == $split) {
569 for my $set (@pic_data) {
570 push @sub_data, [ splice(@$set,0,$size) ];
573 my $pic = new GD::Graph::pie;
576 label => $r->{column_labels}->[$vcol],
578 legend_placement => 'R',
580 #logo_position => 'TL',
585 my $format = $pic->export_format;
587 open(IMG, ">$file.pie.$vcol.$sub_graph.$format") or die "Cannot write '$file.pie.$vcol.$sub_graph.$format'";
592 $pic->plot(\@sub_data) or die $pic->error;
593 print IMG $pic->gd->$format;
596 warn "Couldn't draw $file.pie.$vcol.$sub_graph.$format : $e";
604 { file => "pie.$vcol.$sub_graph.$format",
605 name => $r->{column_labels}->[$vcol].' (Pie)',
606 } unless ($forgetit);
608 last if ($sub_graph == $split);
619 my $data = $r->{data};
621 #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
623 my @groups = @{ $r->{group_by_list} };
626 my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
627 splice(@values,$_,1) for (reverse @groups);
631 for my $row (@$data) {
632 push @{$pic_data[0]}, join(' -- ', @$row[@groups]);
642 for my $vcol (@values) {
643 next unless (defined $vcol);
644 $pic_data[$set] ||= [];
647 for my $row (@$data) {
648 my $val = $$row[$vcol] ? $$row[$vcol] : 0;
649 push @{$pic_data[$set]}, $val;
650 $max_y = $val if ($val > $max_y);
651 $trim_candidates{$pos}++ if ($val == 0);
657 my $set_count = scalar(@pic_data) - 1;
658 my @trim_cols = grep { $trim_candidates{$_} == $set_count } keys %trim_candidates;
664 for my $dataset (@pic_data) {
665 splice(@$dataset,$_,1) for (reverse sort @trim_cols);
667 if (grep { $_ } @$dataset) {
668 push @new_data, $dataset;
669 push @use_me, $set_index if ($set_index > 0);
671 push @no_use, $set_index;
677 return [] unless ($new_data[0] && @{$new_data[0]});
679 for my $col (@use_me) {
680 push @leg, $r->{column_labels}->[$values[$col - 1]];
683 my $w = 100 + 10 * scalar(@{$new_data[0]});
684 $w = 400 if ($w < 400);
686 my $h = 10 * (scalar(@new_data) / 2);
690 my $pic = new GD::Graph::bars3d ($w + 250, $h + 500);
693 title => $r->{report}{name},
694 x_labels_vertical => 1,
698 y_max_value => $max_y,
699 legend_placement => 'TR',
702 #logo_position => 'R',
707 $pic->set_legend(@leg);
709 my $format = $pic->export_format;
711 open(IMG, ">$file.bar.$format") or die "Cannot write '$file.bar.$format'";
715 $pic->plot(\@new_data) or die $pic->error;
716 print IMG $pic->gd->$format;
719 warn "Couldn't draw $file.bar.$format : $e";
724 return [{ file => "bar.$format",
725 name => $r->{report}{name}.' (Bar)',
733 my $data = $r->{data};
735 #my $logo = $doc->findvalue('/reporter/setup/files/chart_logo');
737 my @groups = @{ $r->{group_by_list} };
739 my @values = (0 .. (scalar(@{$r->{column_labels}}) - 1));
740 splice(@values,$_,1) for (reverse @groups);
744 for my $row (@$data) {
745 push @{$pic_data[0]}, join(' -- ', @$row[@groups]);
753 for my $vcol (@values) {
754 next unless (defined $vcol);
755 $pic_data[$set] ||= [];
758 for my $row (@$data) {
759 my $val = $$row[$vcol] ? $$row[$vcol] : 0;
760 push @{$pic_data[$set]}, $val;
761 $max_y = $val if ($val > $max_y);
766 my $set_count = scalar(@pic_data) - 1;
772 for my $dataset (@pic_data) {
774 if (grep { $_ } @$dataset) {
775 push @new_data, $dataset;
776 push @use_me, $set_index if ($set_index > 0);
778 push @no_use, $set_index;
784 return [] unless ($new_data[0] && @{$new_data[0]});
786 for my $col (@use_me) {
787 push @leg, $r->{column_labels}->[$values[$col - 1]];
790 my $w = 100 + 10 * scalar(@{$new_data[0]});
791 $w = 400 if ($w < 400);
793 my $h = 10 * (scalar(@new_data) / 2);
797 my $pic = new GD::Graph::lines3d ($w + 250, $h + 500);
800 title => $r->{report}{name},
801 x_labels_vertical => 1,
804 y_max_value => $max_y,
805 legend_placement => 'TR',
808 #logo_position => 'R',
813 $pic->set_legend(@leg);
815 my $format = $pic->export_format;
817 open(IMG, ">$file.line.$format") or die "Cannot write '$file.line.$format'";
821 $pic->plot(\@new_data) or die $pic->error;
822 print IMG $pic->gd->$format;
825 warn "Couldn't draw $file.line.$format : $e";
830 return [{ file => "line.$format",
831 name => $r->{report}{name}.' (Bar)',
839 my $pivot_label = shift;
840 my $pivot_data = shift;
842 $default = 0 unless (defined $default);
844 my $data = $$blob{data};
845 my $cols = $$blob{columns};
847 my @keep_labels = @$cols;
848 splice(@keep_labels, $_ - 1, 1) for (reverse sort ($pivot_label, $pivot_data));
850 my @keep_cols = (0 .. @$cols - 1);
851 splice(@keep_cols, $_ - 1, 1) for (reverse sort ($pivot_label, $pivot_data));
853 my @gb = ( 0 .. @keep_cols - 1);
855 #first, find the unique list of pivot values
857 for my $row (@$data) {
858 $tmp{ $$row[$pivot_label - 1] } = 1;
860 my @new_cols = sort keys %tmp;
862 tie my %split_data, 'Tie::IxHash';
863 for my $row (@$data) {
865 my $row_fp = ''. join('', map { defined($$row[$_]) ? $$row[$_] : '' } @keep_cols);
866 $split_data{$row_fp} ||= [];
868 push @{ $split_data{$row_fp} }, $row;
872 #now loop over the data, building a new result set
873 tie my %new_data, 'Tie::IxHash';
875 for my $fp ( keys %split_data ) {
879 for my $col (@keep_cols) {
880 push @{ $new_data{$fp} }, $split_data{$fp}[0][$col];
883 for my $col (@new_cols) {
885 my ($datum) = map { $_->[$pivot_data - 1] } grep { $_->[$pivot_label - 1] eq $col } @{ $split_data{$fp} };
887 push @{ $new_data{$fp} }, $datum;
891 push @keep_labels, @new_cols;
893 return { columns => \@keep_labels, data => [ values %new_data ], group_by_list => \@gb };