diff options
author | gerv%gerv.net <> | 2003-06-26 08:22:50 +0200 |
---|---|---|
committer | gerv%gerv.net <> | 2003-06-26 08:22:50 +0200 |
commit | 4f6b75a65628b0d86c760309dd81dd03f5c6d308 (patch) | |
tree | 5363459b06e75bc620ceab9dfd045b0f66b6c27c /Bugzilla | |
parent | da6143f4aae6af35f60b8230b82f649b3b0cbd05 (diff) | |
download | bugzilla-4f6b75a65628b0d86c760309dd81dd03f5c6d308.tar.gz bugzilla-4f6b75a65628b0d86c760309dd81dd03f5c6d308.tar.xz |
Bug 16009 - generic charting. Patch by gerv; r,a=justdave.
Diffstat (limited to 'Bugzilla')
-rw-r--r-- | Bugzilla/Chart.pm | 351 | ||||
-rw-r--r-- | Bugzilla/Series.pm | 262 | ||||
-rw-r--r-- | Bugzilla/Template.pm | 7 |
3 files changed, 620 insertions, 0 deletions
diff --git a/Bugzilla/Chart.pm b/Bugzilla/Chart.pm new file mode 100644 index 000000000..03b5e4173 --- /dev/null +++ b/Bugzilla/Chart.pm @@ -0,0 +1,351 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Gervase Markham <gerv@gerv.net> + +use strict; +use lib "."; + +# 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 Bugzilla::Util; +use Bugzilla::Series; + +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+/+NEW&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"); + push(@{$self->{'lines'}[$1]}, + new Bugzilla::Series($series_id)); + } + } + + # 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'); + + # 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' => $cgi->param('datefrom'), + 'dateto' => $cgi->param('dateto')}); + } +} + +# Alter Chart so that the selected series are added to it. +sub add { + my $self = shift; + my @series_ids = @_; + + # If we are going from < 2 to >= 2 series, add the Grand Total line. + if (!$self->{'gt'}) { + my $current_size = scalar($self->getSeriesIDs()); + if ($current_size < 2 && + $current_size + scalar(@series_ids) >= 2) + { + $self->{'gt'} = 1; + } + } + + # 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); + push(@{$self->{'lines'}}, [$series]); + push(@{$self->{'labels'}}, ""); + } +} + +# 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 $series_ids = join(",", $self->getSeriesIDs()); + + # 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(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(date) FROM series_data " . + "WHERE series_id IN ($series_ids)"); + $dateto = &::str2time($dateto); + + if ($self->{'dateto'} && $self->{'dateto'} < $dateto) { + $dateto = $self->{'dateto'}; + } + + # Prepare the query which retrieves the data for each series + my $query = "SELECT TO_DAYS(date) - TO_DAYS(FROM_UNIXTIME($datefrom)), " . + "value FROM series_data " . + "WHERE series_id = ? " . + "AND date >= FROM_UNIXTIME($datefrom)"; + if ($dateto) { + $query .= " AND date <= FROM_UNIXTIME($dateto)"; + } + + my $sth = $dbh->prepare($query); + + my $gt_index = $self->{'gt'} ? scalar(@{$self->{'lines'}}) : undef; + my $line_index = 0; + + 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] = []; + + foreach my $series (@$line) { + + # Get the data for this series and add it on + $sth->execute($series->{'series_id'}); + my $points = $sth->fetchall_arrayref(); + + foreach my $point (@$points) { + my ($datediff, $value) = @$point; + $data[$line_index][$datediff] ||= 0; + $data[$line_index][$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; + } + } + } + + $line_index++; + } + + # 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; + + # 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 " . + "LEFT JOIN series_categories AS cc1 " . + " ON series.category = cc1.category_id " . + "LEFT JOIN series_categories AS cc2 " . + " ON series.subcategory = cc2.category_id " . + "LEFT JOIN user_series_map AS ucm " . + " ON series.series_id = ucm.series_id " . + "WHERE ucm.user_id = 0 OR ucm.user_id = $::userid"); + + 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; + print "<pre>Bugzilla::Chart object:\n"; + print Data::Dumper::Dumper($self); + print "</pre>"; +} + +1; diff --git a/Bugzilla/Series.pm b/Bugzilla/Series.pm new file mode 100644 index 000000000..bc11389c9 --- /dev/null +++ b/Bugzilla/Series.pm @@ -0,0 +1,262 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Gervase Markham <gerv@gerv.net> + +use strict; +use lib "."; + +# This module implements a series - a set of data to be plotted on a chart. +package Bugzilla::Series; + +use Bugzilla; +use Bugzilla::Util; +use Bugzilla::User; + +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) { + if (ref($_[0])) { + # We've been given a CGI object + $self->readParametersFromCGI($_[0]); + $self->createInDatabase(); + } + else { + # We've been given a series_id. + $self->initFromDatabase($_[0]); + } + } + elsif ($#_ >= 3) { + $self->initFromParameters(@_); + } + else { + die("Bad parameters passed in - invalid number of args \($#_\)($_)"); + } + + return $self->{'already_exists'} ? $self->{'series_id'} : $self; +} + +sub initFromDatabase { + my $self = shift; + my $series_id = shift; + + &::detaint_natural($series_id) + || &::ThrowCodeError("invalid_series_id", { 'series_id' => $series_id }); + + my $dbh = Bugzilla->dbh; + my @series = $dbh->selectrow_array("SELECT series.series_id, cc1.name, " . + "cc2.name, series.name, series.creator, series.frequency, " . + "series.query " . + "FROM series " . + "LEFT JOIN series_categories AS cc1 " . + " ON series.category = cc1.category_id " . + "LEFT JOIN series_categories AS cc2 " . + " ON series.subcategory = cc2.category_id " . + "WHERE series.series_id = $series_id"); + + if (@series) { + $self->initFromParameters(@series); + } + else { + &::ThrowCodeError("invalid_series_id", { 'series_id' => $series_id }); + } +} + +sub initFromParameters { + my $self = shift; + + # The first four parameters are compulsory, unless you immediately call + # createInDatabase(), in which case series_id can be left off. + ($self->{'series_id'}, $self->{'category'}, $self->{'subcategory'}, + $self->{'name'}, $self->{'creator'}, $self->{'frequency'}, + $self->{'query'}) = @_; + + $self->{'public'} = $self->isSubscribed(0); + $self->{'subscribed'} = $self->isSubscribed($::userid); +} + +sub createInDatabase { + my $self = shift; + + # Lock some tables + my $dbh = Bugzilla->dbh; + $dbh->do("LOCK TABLES series_categories WRITE, series WRITE, " . + "user_series_map WRITE"); + + my $category_id = getCategoryID($self->{'category'}); + my $subcategory_id = getCategoryID($self->{'subcategory'}); + + $self->{'creator'} = $::userid; + + # Check for the series currently existing + trick_taint($self->{'name'}); + $self->{'series_id'} = $dbh->selectrow_array("SELECT series_id " . + "FROM series WHERE category = $category_id " . + "AND subcategory = $subcategory_id AND name = " . + $dbh->quote($self->{'name'})); + + if ($self->{'series_id'}) { + $self->{'already_exists'} = 1; + } + else { + trick_taint($self->{'query'}); + + # Insert the new series into the series table + $dbh->do("INSERT INTO series (creator, category, subcategory, " . + "name, frequency, query) VALUES ($self->{'creator'}, " . + "$category_id, $subcategory_id, " . + $dbh->quote($self->{'name'}) . ", $self->{'frequency'}," . + $dbh->quote($self->{'query'}) . ")"); + + # Retrieve series_id + $self->{'series_id'} = $dbh->selectrow_array("SELECT MAX(series_id) " . + "FROM series"); + $self->{'series_id'} + || &::ThrowCodeError("missing_series_id", { 'series' => $self }); + + # Subscribe user to the newly-created series. + $self->subscribe($::userid); + # Public series are subscribed to by userid 0. + $self->subscribe(0) if ($self->{'public'} && $::userid != 0); + } + + $dbh->do("UNLOCK TABLES"); +} + +# Get a category or subcategory IDs, creating the category if it doesn't exist. +sub getCategoryID { + my ($category) = @_; + my $category_id; + my $dbh = Bugzilla->dbh; + + # This seems for the best idiom for "Do A. Then maybe do B and A again." + while (1) { + # We are quoting this to put it in the DB, so we can remove taint + trick_taint($category); + + $category_id = $dbh->selectrow_array("SELECT category_id " . + "from series_categories " . + "WHERE name =" . $dbh->quote($category)); + last if $category_id; + + $dbh->do("INSERT INTO series_categories (name) " . + "VALUES (" . $dbh->quote($category) . ")"); + } + + return $category_id; +} + +sub readParametersFromCGI { + my $self = shift; + my $cgi = shift; + + $self->{'category'} = $cgi->param('category') + || $cgi->param('newcategory') + || &::ThrowUserError("missing_category"); + + $self->{'subcategory'} = $cgi->param('subcategory') + || $cgi->param('newsubcategory') + || &::ThrowUserError("missing_subcategory"); + + $self->{'name'} = $cgi->param('name') + || &::ThrowUserError("missing_name"); + + $self->{'frequency'} = $cgi->param('frequency'); + detaint_natural($self->{'frequency'}) + || &::ThrowUserError("missing_frequency"); + + $self->{'public'} = $cgi->param('public') ? 1 : 0; + + $self->{'query'} = $cgi->canonicalise_query("format", "ctype", "action", + "category", "subcategory", "name", + "frequency", "public", "query_format"); +} + +sub alter { + my $self = shift; + my $cgi = shift; + + my $old_public = $self->{'public'}; + + # Note: $self->{'query'} will be meaningless after this call + $self->readParametersFromCGI($cgi); + + my $category_id = getCategoryID($self->{'category'}); + my $subcategory_id = getCategoryID($self->{'subcategory'}); + + # Update the entry + trick_taint($self->{'name'}); + my $dbh = Bugzilla->dbh; + $dbh->do("UPDATE series SET " . + "category = $category_id, subcategory = $subcategory_id " . + ", name = " . $dbh->quote($self->{'name'}) . + ", frequency = $self->{'frequency'} " . + "WHERE series_id = $self->{'series_id'}"); + + # Update the publicness of this query. + if ($old_public && !$self->{'public'}) { + $self->unsubscribe(0); + } + elsif (!$old_public && $self->{'public'}) { + $self->subscribe(0); + } +} + +sub subscribe { + my $self = shift; + my $userid = shift; + + if (!$self->isSubscribed($userid)) { + # Subscribe current user to series_id + my $dbh = Bugzilla->dbh; + $dbh->do("INSERT INTO user_series_map " . + "VALUES($userid, $self->{'series_id'})"); + } +} + +sub unsubscribe { + my $self = shift; + my $userid = shift; + + if ($self->isSubscribed($userid)) { + # Remove current user's subscription to series_id + my $dbh = Bugzilla->dbh; + $dbh->do("DELETE FROM user_series_map " . + "WHERE user_id = $userid AND series_id = $self->{'series_id'}"); + } +} + +sub isSubscribed { + my $self = shift; + my $userid = shift; + + my $dbh = Bugzilla->dbh; + my $issubscribed = $dbh->selectrow_array("SELECT 1 FROM user_series_map " . + "WHERE user_id = $userid " . + "AND series_id = $self->{'series_id'}"); + return $issubscribed; +} + +1; diff --git a/Bugzilla/Template.pm b/Bugzilla/Template.pm index b83079861..6c3e2161a 100644 --- a/Bugzilla/Template.pm +++ b/Bugzilla/Template.pm @@ -121,6 +121,13 @@ $Template::Stash::LIST_OPS->{ containsany } = return 0; }; +# Allow us to still get the scalar if we use the list operation ".0" on it, +# as we often do for defaults in query.cgi and other places. +$Template::Stash::SCALAR_OPS->{ 0 } = + sub { + return $_[0]; + }; + # Add a "substr" method to the Template Toolkit's "scalar" object # that returns a substring of a string. $Template::Stash::SCALAR_OPS->{ substr } = |