| # This Source Code Form is subject to the terms of the Mozilla Public |
| # License, v. 2.0. If a copy of the MPL was not distributed with this |
| # file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| # |
| # This Source Code Form is "Incompatible With Secondary Licenses", as |
| # defined by the Mozilla Public License, v. 2.0. |
| |
| # This module represents a chart. |
| # |
| # Note that it is perfectly legal for the 'lines' member variable of this |
| # class (which is an array of Bugzilla::Series objects) to have empty members |
| # in it. If this is true, the 'labels' array will also have empty members at |
| # the same points. |
| package Bugzilla::Chart; |
| |
| use 5.10.1; |
| use strict; |
| use warnings; |
| |
| use Bugzilla::Error; |
| use Bugzilla::Util; |
| use Bugzilla::Series; |
| |
| use Date::Format; |
| use Date::Parse; |
| use List::Util qw(max); |
| |
| sub new { |
| my $invocant = shift; |
| my $class = ref($invocant) || $invocant; |
| |
| # Create a ref to an empty hash and bless it |
| my $self = {}; |
| bless($self, $class); |
| |
| if ($#_ == 0) { |
| # Construct from a CGI object. |
| $self->init($_[0]); |
| } |
| else { |
| die("CGI object not passed in - invalid number of args \($#_\)($_)"); |
| } |
| |
| return $self; |
| } |
| |
| sub init { |
| my $self = shift; |
| my $cgi = shift; |
| |
| # The data structure is a list of lists (lines) of Series objects. |
| # There is a separate list for the labels. |
| # |
| # The URL encoding is: |
| # line0=67&line0=73&line1=81&line2=67... |
| # &label0=B+/+R+/+CONFIRMED&label1=... |
| # &select0=1&select3=1... |
| # &cumulate=1&datefrom=2002-02-03&dateto=2002-04-04&ctype=html... |
| # >=1&labelgt=Grand+Total |
| foreach my $param ($cgi->param()) { |
| # Store all the lines |
| if ($param =~ /^line(\d+)$/) { |
| foreach my $series_id ($cgi->param($param)) { |
| detaint_natural($series_id) |
| || ThrowCodeError("invalid_series_id"); |
| my $series = new Bugzilla::Series($series_id); |
| push(@{$self->{'lines'}[$1]}, $series) if $series; |
| } |
| } |
| |
| # Store all the labels |
| if ($param =~ /^label(\d+)$/) { |
| $self->{'labels'}[$1] = $cgi->param($param); |
| } |
| } |
| |
| # Store the miscellaneous metadata |
| $self->{'cumulate'} = $cgi->param('cumulate') ? 1 : 0; |
| $self->{'gt'} = $cgi->param('gt') ? 1 : 0; |
| $self->{'labelgt'} = $cgi->param('labelgt'); |
| $self->{'datefrom'} = $cgi->param('datefrom'); |
| $self->{'dateto'} = $cgi->param('dateto'); |
| |
| # If we are cumulating, a grand total makes no sense |
| $self->{'gt'} = 0 if $self->{'cumulate'}; |
| |
| # Make sure the dates are ones we are able to interpret |
| foreach my $date ('datefrom', 'dateto') { |
| if ($self->{$date}) { |
| $self->{$date} = str2time($self->{$date}) |
| || ThrowUserError("illegal_date", { date => $self->{$date}}); |
| } |
| } |
| |
| # datefrom can't be after dateto |
| if ($self->{'datefrom'} && $self->{'dateto'} && |
| $self->{'datefrom'} > $self->{'dateto'}) |
| { |
| ThrowUserError('misarranged_dates', { 'datefrom' => scalar $cgi->param('datefrom'), |
| 'dateto' => scalar $cgi->param('dateto') }); |
| } |
| } |
| |
| # Alter Chart so that the selected series are added to it. |
| sub add { |
| my $self = shift; |
| my @series_ids = @_; |
| |
| # Get the current size of the series; required for adding Grand Total later |
| my $current_size = scalar($self->getSeriesIDs()); |
| |
| # Count the number of added series |
| my $added = 0; |
| # Create new Series and push them on to the list of lines. |
| # Note that new lines have no label; the display template is responsible |
| # for inventing something sensible. |
| foreach my $series_id (@series_ids) { |
| my $series = new Bugzilla::Series($series_id); |
| if ($series) { |
| push(@{$self->{'lines'}}, [$series]); |
| push(@{$self->{'labels'}}, ""); |
| $added++; |
| } |
| } |
| |
| # If we are going from < 2 to >= 2 series, add the Grand Total line. |
| if (!$self->{'gt'}) { |
| if ($current_size < 2 && |
| $current_size + $added >= 2) |
| { |
| $self->{'gt'} = 1; |
| } |
| } |
| } |
| |
| # Alter Chart so that the selections are removed from it. |
| sub remove { |
| my $self = shift; |
| my @line_ids = @_; |
| |
| foreach my $line_id (@line_ids) { |
| if ($line_id == 65536) { |
| # Magic value - delete Grand Total. |
| $self->{'gt'} = 0; |
| } |
| else { |
| delete($self->{'lines'}->[$line_id]); |
| delete($self->{'labels'}->[$line_id]); |
| } |
| } |
| } |
| |
| # Alter Chart so that the selections are summed. |
| sub sum { |
| my $self = shift; |
| my @line_ids = @_; |
| |
| # We can't add the Grand Total to things. |
| @line_ids = grep(!/^65536$/, @line_ids); |
| |
| # We can't add less than two things. |
| return if scalar(@line_ids) < 2; |
| |
| my @series; |
| my $label = ""; |
| my $biggestlength = 0; |
| |
| # We rescue the Series objects of all the series involved in the sum. |
| foreach my $line_id (@line_ids) { |
| my @line = @{$self->{'lines'}->[$line_id]}; |
| |
| foreach my $series (@line) { |
| push(@series, $series); |
| } |
| |
| # We keep the label that labels the line with the most series. |
| if (scalar(@line) > $biggestlength) { |
| $biggestlength = scalar(@line); |
| $label = $self->{'labels'}->[$line_id]; |
| } |
| } |
| |
| $self->remove(@line_ids); |
| |
| push(@{$self->{'lines'}}, \@series); |
| push(@{$self->{'labels'}}, $label); |
| } |
| |
| sub data { |
| my $self = shift; |
| $self->{'_data'} ||= $self->readData(); |
| return $self->{'_data'}; |
| } |
| |
| # Convert the Chart's data into a plottable form in $self->{'_data'}. |
| sub readData { |
| my $self = shift; |
| my @data; |
| my @maxvals; |
| |
| # Note: you get a bad image if getSeriesIDs returns nothing |
| # We need to handle errors better. |
| my $series_ids = join(",", $self->getSeriesIDs()); |
| |
| return [] unless $series_ids; |
| |
| # Work out the date boundaries for our data. |
| my $dbh = Bugzilla->dbh; |
| |
| # The date used is the one given if it's in a sensible range; otherwise, |
| # it's the earliest or latest date in the database as appropriate. |
| my $datefrom = $dbh->selectrow_array("SELECT MIN(series_date) " . |
| "FROM series_data " . |
| "WHERE series_id IN ($series_ids)"); |
| $datefrom = str2time($datefrom); |
| |
| if ($self->{'datefrom'} && $self->{'datefrom'} > $datefrom) { |
| $datefrom = $self->{'datefrom'}; |
| } |
| |
| my $dateto = $dbh->selectrow_array("SELECT MAX(series_date) " . |
| "FROM series_data " . |
| "WHERE series_id IN ($series_ids)"); |
| $dateto = str2time($dateto); |
| |
| if ($self->{'dateto'} && $self->{'dateto'} < $dateto) { |
| $dateto = $self->{'dateto'}; |
| } |
| |
| # Convert UNIX times back to a date format usable for SQL queries. |
| my $sql_from = time2str('%Y-%m-%d', $datefrom); |
| my $sql_to = time2str('%Y-%m-%d', $dateto); |
| |
| # Prepare the query which retrieves the data for each series |
| my $query = "SELECT " . $dbh->sql_to_days('series_date') . " - " . |
| $dbh->sql_to_days('?') . ", series_value " . |
| "FROM series_data " . |
| "WHERE series_id = ? " . |
| "AND series_date >= ?"; |
| if ($dateto) { |
| $query .= " AND series_date <= ?"; |
| } |
| |
| my $sth = $dbh->prepare($query); |
| |
| my $gt_index = $self->{'gt'} ? scalar(@{$self->{'lines'}}) : undef; |
| my $line_index = 0; |
| |
| $maxvals[$gt_index] = 0 if $gt_index; |
| |
| my @datediff_total; |
| |
| foreach my $line (@{$self->{'lines'}}) { |
| # Even if we end up with no data, we need an empty arrayref to prevent |
| # errors in the PNG-generating code |
| $data[$line_index] = []; |
| $maxvals[$line_index] = 0; |
| |
| foreach my $series (@$line) { |
| |
| # Get the data for this series and add it on |
| if ($dateto) { |
| $sth->execute($sql_from, $series->{'series_id'}, $sql_from, $sql_to); |
| } |
| else { |
| $sth->execute($sql_from, $series->{'series_id'}, $sql_from); |
| } |
| my $points = $sth->fetchall_arrayref(); |
| |
| foreach my $point (@$points) { |
| my ($datediff, $value) = @$point; |
| $data[$line_index][$datediff] ||= 0; |
| $data[$line_index][$datediff] += $value; |
| if ($data[$line_index][$datediff] > $maxvals[$line_index]) { |
| $maxvals[$line_index] = $data[$line_index][$datediff]; |
| } |
| |
| $datediff_total[$datediff] += $value; |
| |
| # Add to the grand total, if we are doing that |
| if ($gt_index) { |
| $data[$gt_index][$datediff] ||= 0; |
| $data[$gt_index][$datediff] += $value; |
| if ($data[$gt_index][$datediff] > $maxvals[$gt_index]) { |
| $maxvals[$gt_index] = $data[$gt_index][$datediff]; |
| } |
| } |
| } |
| } |
| |
| # We are done with the series making up this line, go to the next one |
| $line_index++; |
| } |
| |
| # calculate maximum y value |
| if ($self->{'cumulate'}) { |
| # Make sure we do not try to take the max of an array with undef values |
| my @processed_datediff; |
| while (@datediff_total) { |
| my $datediff = shift @datediff_total; |
| push @processed_datediff, $datediff if defined($datediff); |
| } |
| $self->{'y_max_value'} = max(@processed_datediff); |
| } |
| else { |
| $self->{'y_max_value'} = max(@maxvals); |
| } |
| $self->{'y_max_value'} |= 1; # For log() |
| |
| # Align the max y value: |
| # For one- or two-digit numbers, increase y_max_value until divisible by 8 |
| # For larger numbers, see the comments below to figure out what's going on |
| if ($self->{'y_max_value'} < 100) { |
| do { |
| ++$self->{'y_max_value'}; |
| } while ($self->{'y_max_value'} % 8 != 0); |
| } |
| else { |
| # First, get the # of digits in the y_max_value |
| my $num_digits = 1+int(log($self->{'y_max_value'})/log(10)); |
| |
| # We want to zero out all but the top 2 digits |
| my $mask_length = $num_digits - 2; |
| $self->{'y_max_value'} /= 10**$mask_length; |
| $self->{'y_max_value'} = int($self->{'y_max_value'}); |
| $self->{'y_max_value'} *= 10**$mask_length; |
| |
| # Add 10^$mask_length to the max value |
| # Continue to increase until it's divisible by 8 * 10^($mask_length-1) |
| # (Throwing in the -1 keeps at least the smallest digit at zero) |
| do { |
| $self->{'y_max_value'} += 10**$mask_length; |
| } while ($self->{'y_max_value'} % (8*(10**($mask_length-1))) != 0); |
| } |
| |
| |
| # Add the x-axis labels into the data structure |
| my $date_progression = generateDateProgression($datefrom, $dateto); |
| unshift(@data, $date_progression); |
| |
| if ($self->{'gt'}) { |
| # Add Grand Total to label list |
| push(@{$self->{'labels'}}, $self->{'labelgt'}); |
| |
| $data[$gt_index] ||= []; |
| } |
| |
| return \@data; |
| } |
| |
| # Flatten the data structure into a list of series_ids |
| sub getSeriesIDs { |
| my $self = shift; |
| my @series_ids; |
| |
| foreach my $line (@{$self->{'lines'}}) { |
| foreach my $series (@$line) { |
| push(@series_ids, $series->{'series_id'}); |
| } |
| } |
| |
| return @series_ids; |
| } |
| |
| # Class method to get the data necessary to populate the "select series" |
| # widgets on various pages. |
| sub getVisibleSeries { |
| my %cats; |
| |
| my $grouplist = Bugzilla->user->groups_as_string; |
| |
| # Get all visible series |
| my $dbh = Bugzilla->dbh; |
| my $serieses = $dbh->selectall_arrayref("SELECT cc1.name, cc2.name, " . |
| "series.name, series.series_id " . |
| "FROM series " . |
| "INNER JOIN series_categories AS cc1 " . |
| " ON series.category = cc1.id " . |
| "INNER JOIN series_categories AS cc2 " . |
| " ON series.subcategory = cc2.id " . |
| "LEFT JOIN category_group_map AS cgm " . |
| " ON series.category = cgm.category_id " . |
| " AND cgm.group_id NOT IN($grouplist) " . |
| "WHERE creator = ? OR (is_public = 1 AND cgm.category_id IS NULL) " . |
| $dbh->sql_group_by('series.series_id', 'cc1.name, cc2.name, ' . |
| 'series.name'), |
| undef, Bugzilla->user->id); |
| foreach my $series (@$serieses) { |
| my ($cat, $subcat, $name, $series_id) = @$series; |
| $cats{$cat}{$subcat}{$name} = $series_id; |
| } |
| |
| return \%cats; |
| } |
| |
| sub generateDateProgression { |
| my ($datefrom, $dateto) = @_; |
| my @progression; |
| |
| $dateto = $dateto || time(); |
| my $oneday = 60 * 60 * 24; |
| |
| # When the from and to dates are converted by str2time(), you end up with |
| # a time figure representing midnight at the beginning of that day. We |
| # adjust the times by 1/3 and 2/3 of a day respectively to prevent |
| # edge conditions in time2str(). |
| $datefrom += $oneday / 3; |
| $dateto += (2 * $oneday) / 3; |
| |
| while ($datefrom < $dateto) { |
| push (@progression, time2str("%Y-%m-%d", $datefrom)); |
| $datefrom += $oneday; |
| } |
| |
| return \@progression; |
| } |
| |
| sub dump { |
| my $self = shift; |
| |
| # Make sure we've read in our data |
| my $data = $self->data; |
| |
| require Data::Dumper; |
| say "<pre>Bugzilla::Chart object:"; |
| print html_quote(Data::Dumper::Dumper($self)); |
| print "</pre>"; |
| } |
| |
| 1; |
| |
| =head1 B<Methods in need of POD> |
| |
| =over |
| |
| =item remove |
| |
| =item add |
| |
| =item dump |
| |
| =item readData |
| |
| =item getSeriesIDs |
| |
| =item data |
| |
| =item init |
| |
| =item getVisibleSeries |
| |
| =item generateDateProgression |
| |
| =item sum |
| |
| =back |