summaryrefslogtreecommitdiffstats
path: root/Bugzilla
diff options
context:
space:
mode:
authorgerv%gerv.net <>2003-06-26 08:22:50 +0200
committergerv%gerv.net <>2003-06-26 08:22:50 +0200
commit4f6b75a65628b0d86c760309dd81dd03f5c6d308 (patch)
tree5363459b06e75bc620ceab9dfd045b0f66b6c27c /Bugzilla
parentda6143f4aae6af35f60b8230b82f649b3b0cbd05 (diff)
downloadbugzilla-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.pm351
-rw-r--r--Bugzilla/Series.pm262
-rw-r--r--Bugzilla/Template.pm7
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...
+ # &gt=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 } =