Bug 16009 - generic charting. Patch by gerv; r,a=justdave.

This commit is contained in:
gerv%gerv.net 2003-06-25 23:23:13 +00:00
parent 33e4a9a297
commit a5a1140ff7
23 changed files with 2130 additions and 6 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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 } =

View File

@ -173,6 +173,18 @@ sub LookupNamedQuery {
return $result;
}
sub LookupSeries {
my ($series_id) = @_;
detaint_natural($series_id) || ThrowCodeError("invalid_series_id");
my $dbh = Bugzilla->dbh;
my $result = $dbh->selectrow_array("SELECT query FROM series " .
"WHERE series_id = $series_id");
$result
|| ThrowCodeError("invalid_series_id", {'series_id' => $series_id});
return $result;
}
sub GetQuip {
my $quip;
@ -256,6 +268,12 @@ if ($::FORM{'cmdtype'} eq "dorem") {
$params = new Bugzilla::CGI($::buffer);
$order = $params->param('order') || $order;
}
elsif ($::FORM{'remaction'} eq "runseries") {
$::buffer = LookupSeries($::FORM{"series_id"});
$vars->{'title'} = "Bug List: $::FORM{'namedcmd'}";
$params = new Bugzilla::CGI($::buffer);
$order = $params->param('order') || $order;
}
elsif ($::FORM{'remaction'} eq "load") {
my $url = "query.cgi?" . LookupNamedQuery($::FORM{"namedcmd"});
print $cgi->redirect(-location=>$url);

312
webtools/bugzilla/chart.cgi Executable file
View File

@ -0,0 +1,312 @@
#!/usr/bonsaitools/bin/perl -wT
# -*- 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>
# Glossary:
# series: An individual, defined set of data plotted over time.
# line: A set of one or more series, to be summed and drawn as a single
# line when the series is plotted.
# chart: A set of lines
# So when you select rows in the UI, you are selecting one or more lines, not
# series.
# Generic Charting TODO:
#
# JS-less chart creation - hard.
# Broken image on error or no data - need to do much better.
# Centralise permission checking, so UserInGroup('editbugs') not scattered
# everywhere.
# Better protection on collectstats.pl for second run in a day
# User documentation :-)
#
# Bonus:
# Offer subscription when you get a "series already exists" error?
use strict;
use lib qw(.);
require "CGI.pl";
use Bugzilla::Chart;
use Bugzilla::Series;
use vars qw($cgi $template $vars);
# Go back to query.cgi if we are adding a boolean chart parameter.
if (grep(/^cmd-/, $cgi->param())) {
my $params = $cgi->canonicalise_query("format", "ctype", "action");
print "Location: query.cgi?format=" . $cgi->param('query_format') .
($params ? "&$params" : "") . "\n\n";
exit;
}
my $template = Bugzilla->template;
my $action = $cgi->param('action');
my $series_id = $cgi->param('series_id');
# Because some actions are chosen by buttons, we can't encode them as the value
# of the action param, because that value is localisation-dependent. So, we
# encode it in the name, as "action-<action>". Some params even contain the
# series_id they apply to (e.g. subscribe, unsubscribe.)
my @actions = grep(/^action-/, $cgi->param());
if ($actions[0] && $actions[0] =~ /^action-([^\d]+)(\d*)$/) {
$action = $1;
$series_id = $2 if $2;
}
$action ||= "assemble";
# Go to buglist.cgi if we are doing a search.
if ($action eq "search") {
my $params = $cgi->canonicalise_query("format", "ctype", "action");
print "Location: buglist.cgi" . ($params ? "?$params" : "") . "\n\n";
exit;
}
ConnectToDatabase();
confirm_login();
# All these actions relate to chart construction.
if ($action =~ /^(assemble|add|remove|sum|subscribe|unsubscribe)$/) {
# These two need to be done before the creation of the Chart object, so
# that the changes they make will be reflected in it.
if ($action =~ /^subscribe|unsubscribe$/) {
my $series = new Bugzilla::Series($series_id);
$series->$action($::userid);
}
my $chart = new Bugzilla::Chart($cgi);
if ($action =~ /^remove|sum$/) {
$chart->$action(getSelectedLines());
}
elsif ($action eq "add") {
my @series_ids = getAndValidateSeriesIDs();
$chart->add(@series_ids);
}
view($chart);
}
elsif ($action eq "plot") {
plot();
}
elsif ($action eq "wrap") {
# For CSV "wrap", we go straight to "plot".
if ($cgi->param('ctype') && $cgi->param('ctype') eq "csv") {
plot();
}
else {
wrap();
}
}
elsif ($action eq "create") {
assertCanCreate($cgi);
my $series = new Bugzilla::Series($cgi);
if (ref($series)) {
$vars->{'message'} = "series_created";
}
else {
$vars->{'message'} = "series_already_exists";
$series = new Bugzilla::Series($series);
}
$vars->{'series'} = $series;
print "Content-Type: text/html\n\n";
$template->process("global/message.html.tmpl", $vars)
|| ThrowTemplateError($template->error());
}
elsif ($action eq "edit") {
$series_id || ThrowCodeError("invalid_series_id");
assertCanEdit($series_id);
my $series = new Bugzilla::Series($series_id);
edit($series);
}
elsif ($action eq "alter") {
$series_id || ThrowCodeError("invalid_series_id");
assertCanEdit($series_id);
my $series = new Bugzilla::Series($series_id);
$series->alter($cgi);
edit($series);
}
else {
ThrowCodeError("unknown_action");
}
exit;
# Find any selected series and return either the first or all of them.
sub getAndValidateSeriesIDs {
my @series_ids = grep(/^\d+$/, $cgi->param("name"));
return wantarray ? @series_ids : $series_ids[0];
}
# Return a list of IDs of all the lines selected in the UI.
sub getSelectedLines {
my @ids = map { /^select(\d+)$/ ? $1 : () } $cgi->param();
return @ids;
}
# Check if the user is the owner of series_id or is an admin.
sub assertCanEdit {
my ($series_id) = @_;
return if UserInGroup("admin");
my $dbh = Bugzilla->dbh;
my $iscreator = $dbh->selectrow_array("SELECT creator = ? FROM series " .
"WHERE series_id = ?", undef,
$::userid, $series_id);
$iscreator || ThrowUserError("illegal_series_edit");
}
# Check if the user is permitted to create this series with these parameters.
sub assertCanCreate {
my ($cgi) = shift;
UserInGroup("editbugs") || ThrowUserError("illegal_series_creation");
# Only admins may create public queries
UserInGroup('admin') || $cgi->delete('public');
# Check permission for frequency
my $min_freq = 7;
if ($cgi->param('frequency') < $min_freq && !UserInGroup("admin")) {
ThrowUserError("illegal_frequency", { 'minimum' => $min_freq });
}
}
sub validateWidthAndHeight {
$vars->{'width'} = $cgi->param('width');
$vars->{'height'} = $cgi->param('height');
if (defined($vars->{'width'})) {
(detaint_natural($vars->{'width'}) && $vars->{'width'} > 0)
|| ThrowCodeError("invalid_dimensions");
}
if (defined($vars->{'height'})) {
(detaint_natural($vars->{'height'}) && $vars->{'height'} > 0)
|| ThrowCodeError("invalid_dimensions");
}
# The equivalent of 2000 square seems like a very reasonable maximum size.
# This is merely meant to prevent accidental or deliberate DOS, and should
# have no effect in practice.
if ($vars->{'width'} && $vars->{'height'}) {
(($vars->{'width'} * $vars->{'height'}) <= 4000000)
|| ThrowUserError("chart_too_large");
}
}
sub edit {
my $series = shift;
$vars->{'category'} = Bugzilla::Chart::getVisibleSeries();
$vars->{'creator'} = new Bugzilla::User($series->{'creator'});
# If we've got any parameters, use those in preference to the values
# read from the database. This is a bit ugly, but I can't see a better
# way to make this work in the no-JS situation.
if ($cgi->param('category') || $cgi->param('subcategory') ||
$cgi->param('name') || $cgi->param('frequency') ||
$cgi->param('public'))
{
$vars->{'default'} = new Bugzilla::Series($series->{'series_id'},
$cgi->param('category') || $series->{'category'},
$cgi->param('subcategory') || $series->{'subcategory'},
$cgi->param('name') || $series->{'name'},
$series->{'creator'},
$cgi->param('frequency') || $series->{'frequency'});
$vars->{'default'}{'public'}
= $cgi->param('public') || $series->{'public'};
}
else {
$vars->{'default'} = $series;
}
print "Content-Type: text/html\n\n";
$template->process("reports/edit-series.html.tmpl", $vars)
|| ThrowTemplateError($template->error());
}
sub plot {
validateWidthAndHeight();
$vars->{'chart'} = new Bugzilla::Chart($cgi);
my $format = &::GetFormat("reports/chart",
"",
$cgi->param('ctype'));
# Debugging PNGs is a pain; we need to be able to see the error messages
if ($cgi->param('debug')) {
print "Content-Type: text/html\n\n";
$vars->{'chart'}->dump();
}
print "Content-Type: $format->{'ctype'}\n\n";
$template->process($format->{'template'}, $vars)
|| ThrowTemplateError($template->error());
}
sub wrap {
validateWidthAndHeight();
# We create a Chart object so we can validate the parameters
my $chart = new Bugzilla::Chart($cgi);
$vars->{'time'} = time();
$vars->{'imagebase'} = $cgi->canonicalise_query(
"action", "action-wrap", "ctype", "format", "width", "height");
print "Content-Type:text/html\n\n";
$template->process("reports/chart.html.tmpl", $vars)
|| ThrowTemplateError($template->error());
}
sub view {
my $chart = shift;
# Set defaults
foreach my $field ('category', 'subcategory', 'name', 'ctype') {
$vars->{'default'}{$field} = $cgi->param($field) || 0;
}
# Pass the state object to the display UI.
$vars->{'chart'} = $chart;
$vars->{'category'} = Bugzilla::Chart::getVisibleSeries();
print "Content-Type: text/html\n\n";
# If we have having problems with bad data, we can set debug=1 to dump
# the data structure.
$chart->dump() if $cgi->param('debug');
$template->process("reports/create-chart.html.tmpl", $vars)
|| ThrowTemplateError($template->error());
}

View File

@ -26,6 +26,7 @@
# Jacob Steenhagen <jake@bugzilla.org>
# Bradley Baetz <bbaetz@student.usyd.edu.au>
# Tobias Burnus <burnus@net-b.de>
# Gervase Markham <gerv@gerv.net>
#
#
# Direct any questions on this source code to
@ -112,6 +113,8 @@
#
use strict;
use lib ".";
use vars qw( $db_name %answer );
use Bugzilla::Constants;
@ -1737,6 +1740,42 @@ $table{group_control_map} =
unique(product_id, group_id),
index(group_id)';
# 2003-06-26 gerv@gerv.net, bug 16009
# Generic charting over time of arbitrary queries.
# Queries are disabled when frequency == 0.
$table{series} =
'series_id mediumint auto_increment primary key,
creator mediumint not null,
category smallint not null,
subcategory smallint not null,
name varchar(64) not null,
frequency smallint not null,
last_viewed datetime default null,
query mediumtext not null,
index(creator),
unique(creator, category, subcategory, name)';
$table{series_data} =
'series_id mediumint not null,
date datetime not null,
value mediumint not null,
unique(series_id, date)';
$table{user_series_map} =
'user_id mediumint not null,
series_id mediumint not null,
index(series_id),
unique(user_id, series_id)';
$table{series_categories} =
'category_id smallint auto_increment primary key,
name varchar(64) not null,
unique(name)';
###########################################################################
# Create tables
###########################################################################
@ -3530,6 +3569,109 @@ if ($mapcnt == 0) {
}
}
# 2003-06-26 Copy the old charting data into the database, and create the
# queries that will keep it all running. When the old charting system goes
# away, if this code ever runs, it'll just find no files and do nothing.
my $series_exists = $dbh->selectrow_array("SELECT 1 FROM series LIMIT 1");
if (!$series_exists) {
print "Migrating old chart data into database ...\n" unless $silent;
use Bugzilla::Series;
# We prepare the handle to insert the series data
my$seriesdatasth = $dbh->prepare("INSERT INTO series_data " .
"(series_id, date, value) " .
"VALUES (?, ?, ?)");
# Fields in the data file (matches the current collectstats.pl)
my @statuses =
qw(NEW ASSIGNED REOPENED UNCONFIRMED RESOLVED VERIFIED CLOSED);
my @resolutions =
qw(FIXED INVALID WONTFIX LATER REMIND DUPLICATE WORKSFORME MOVED);
my @fields = (@statuses, @resolutions);
# We have a localisation problem here. Where do we get these values?
my $all_name = "-All-";
my $open_name = "All Open";
# We can't give the Series we create a meaningful owner; that's not a big
# problem. But we do need to set this global, otherwise Series.pm objects.
$::userid = 0;
my $products = $dbh->selectall_arrayref("SELECT name FROM products");
foreach my $product ((map { $_->[0] } @$products), "-All-") {
# First, create the series
my %queries;
my %seriesids;
my $query_prod = "";
if ($product ne "-All-") {
$query_prod = "product=" . html_quote($product) . "&";
}
# The query for statuses is different to that for resolutions.
$queries{$_} = ($query_prod . "status=$_") foreach (@statuses);
$queries{$_} = ($query_prod . "resolution=$_") foreach (@resolutions);
foreach my $field (@fields) {
# Create a Series for each field in this product
my $series = new Bugzilla::Series(-1, $product, $all_name,
$field, $::userid, 1,
$queries{$field});
$series->createInDatabase();
$seriesids{$field} = $series->{'series_id'};
}
# We also add a new query for "Open", so that migrated products get
# the same set as new products (see editproducts.cgi.)
my @openedstatuses = ("UNCONFIRMED", "NEW", "ASSIGNED", "REOPENED");
my $query = join("&", map { "bug_status=$_" } @openedstatuses);
my $series = new Bugzilla::Series(-1, $product, $all_name,
$open_name, $::userid, 1,
$query_prod . $query);
$series->createInDatabase();
# Now, we attempt to read in historical data, if any
# Convert the name in the same way that collectstats.pl does
my $product_file = $product;
$product_file =~ s/\//-/gs;
$product_file = "data/mining/$product_file";
# There are many reasons that this might fail (e.g. no stats for this
# product), so we don't worry if it does.
open(IN, $product_file) or next;
# The data files should be in a standard format, even for old
# Bugzillas, because of the conversion code further up this file.
my %data;
while (<IN>) {
if (/^(\d+\|.*)/) {
my @numbers = split(/\||\r/, $1);
for my $i (0 .. $#fields) {
# $numbers[0] is the date
$data{$fields[$i]}{$numbers[0]} = $numbers[$i + 1];
}
}
}
close(IN);
foreach my $field (@fields) {
# Insert values into series_data: series_id, date, value
my %fielddata = %{$data{$field}};
foreach my $date (keys %fielddata) {
# We prepared this above
$seriesdatasth->execute($seriesids{$field},
$dbh->quote($date),
$fielddata{$date});
}
}
}
}
# If you had to change the --TABLE-- definition in any way, then add your
# differential change code *** A B O V E *** this comment.
#

View File

@ -32,7 +32,10 @@ use strict;
use IO::Handle;
use vars @::legal_product;
use lib ".";
require "globals.pl";
use Bugzilla::Search;
use Bugzilla::User;
use Bugzilla;
@ -79,6 +82,8 @@ my $tend = time;
&calculate_dupes();
CollectSeriesData();
# Generate a static RDF file containing the default view of the duplicates data.
open(CGI, "GATEWAY_INTERFACE=cmdline REQUEST_METHOD=GET QUERY_STRING=ctype=rdf ./duplicates.cgi |")
|| die "can't fork duplicates.cgi: $!";
@ -421,3 +426,71 @@ sub delta_time {
my $seconds = $delta - ($minutes * 60) - ($hours * 3600);
return sprintf("%02d:%02d:%02d" , $hours, $minutes, $seconds);
}
sub CollectSeriesData {
# We need some way of randomising the distribution of series, such that
# all of the series which are to be run every 7 days don't run on the same
# day. This is because this might put the server under severe load if a
# particular frequency, such as once a week, is very common. We achieve
# this by only running queries when:
# (days_since_epoch + series_id) % frequency = 0. So they'll run every
# <frequency> days, but the start date depends on the series_id.
my $days_since_epoch = int(time() / (60 * 60 * 24));
my $today = today_dash();
CleanupChartTables() if ($days_since_epoch % 7 == 0);
my $dbh = Bugzilla->dbh;
my $serieses = $dbh->selectall_hashref("SELECT series_id, query " .
"FROM series " .
"WHERE frequency != 0 AND " .
"($days_since_epoch + series_id) % frequency = 0",
"series_id");
# We prepare the insertion into the data table, for efficiency.
my $sth = $dbh->prepare("INSERT INTO series_data " .
"(series_id, date, value) " .
"VALUES (?, " . $dbh->quote($today) . ", ?)");
foreach my $series_id (keys %$serieses) {
# We set up the user for Search.pm's permission checking - each series
# runs with the permissions of its creator.
$::vars->{'user'} =
new Bugzilla::User($serieses->{$series_id}->{'creator'});
my $cgi = new Bugzilla::CGI($serieses->{$series_id}->{'query'});
my $search = new Bugzilla::Search('params' => $cgi,
'fields' => ["bugs.bug_id"]);
my $sql = $search->getSQL();
# We need to count the returned rows. Without subselects, we can't
# do this directly in the SQL for all queries. So we do it by hand.
my $data = $dbh->selectall_arrayref($sql);
my $count = scalar(@$data) || 0;
$sth->execute($series_id, $count);
}
}
sub CleanupChartTables {
my $dbh = Bugzilla->dbh;
$dbh->do("LOCK TABLES series WRITE, user_series_map AS usm READ");
# Find all those that no-one subscribes to
my $series_data = $dbh->selectall_arrayref("SELECT series.series_id " .
"FROM series LEFT JOIN user_series_map AS usm " .
"ON series.series_id = usm.series_id " .
"WHERE usm.series_id IS NULL");
my $series_ids = join(",", map({ $_->[0] } @$series_data));
# Stop collecting data on all series which no-one is subscribed to.
if ($series_ids) {
$dbh->do("UPDATE series SET frequency = 0 " .
"WHERE series_id IN($series_ids)");
}
$dbh->do("UNLOCK TABLES");
}

View File

@ -31,6 +31,8 @@ use lib ".";
require "CGI.pl";
require "globals.pl";
use Bugzilla::Series;
# Shut up misguided -w warnings about "used only once". For some reason,
# "use vars" chokes on me when I try it here.
@ -352,6 +354,8 @@ if ($action eq 'add') {
print "</TR></TABLE>\n<HR>\n";
print "<INPUT TYPE=SUBMIT VALUE=\"Add\">\n";
print "<INPUT TYPE=HIDDEN NAME=\"action\" VALUE=\"new\">\n";
print "<INPUT TYPE=HIDDEN NAME='open_name' VALUE='All Open'>\n";
print "<INPUT TYPE=HIDDEN NAME='closed_name' VALUE='All Closed'>\n";
print "</FORM>";
my $other = $localtrailer;
@ -440,6 +444,32 @@ if ($action eq 'new') {
SqlQuote($initialownerid) . "," .
SqlQuote($initialqacontactid) . ")");
# Insert default charting queries for this product.
# If they aren't using charting, this won't do any harm.
GetVersionTable();
my @series;
my $prodcomp = "&product=$product&component=$component";
# For localisation reasons, we get the title of the queries from the
# submitted form.
my @openedstatuses = ("UNCONFIRMED", "NEW", "ASSIGNED", "REOPENED");
my $statuses = join("&", map { "bug_status=$_" } @openedstatuses);
push(@series, [$::FORM{'open_name'}, $statuses . $prodcomp]);
my $resolved = "field0-0-0=resolution&type0-0-0=notequals&value0-0-0=---";
push(@series, [$::FORM{'closed_name'}, $resolved . $prodcomp]);
foreach my $sdata (@series) {
# We create the series with an nonsensical series_id, which is
# guaranteed not to exist. This is OK, because we immediately call
# createInDatabase().
my $series = new Bugzilla::Series(-1, $product, $component,
$sdata->[0], $::userid, 1,
$sdata->[1]);
$series->createInDatabase();
}
# Make versioncache flush
unlink "data/versioncache";

View File

@ -33,9 +33,11 @@ use vars qw ($template $vars);
use Bugzilla::Constants;
require "CGI.pl";
require "globals.pl";
use Bugzilla::Series;
# Shut up misguided -w warnings about "used only once". "use vars" just
# doesn't work for me.
use vars qw(@legal_bug_status @legal_resolution);
sub sillyness {
my $zz;
@ -272,6 +274,8 @@ if ($action eq 'add') {
print "</TABLE>\n<HR>\n";
print "<INPUT TYPE=SUBMIT VALUE=\"Add\">\n";
print "<INPUT TYPE=HIDDEN NAME=\"action\" VALUE=\"new\">\n";
print "<INPUT TYPE=HIDDEN NAME='subcategory' VALUE='-All-'>\n";
print "<INPUT TYPE=HIDDEN NAME='open_name' VALUE='All Open'>\n";
print "</FORM>";
my $other = $localtrailer;
@ -349,7 +353,7 @@ if ($action eq 'new') {
# If we're using bug groups, then we need to create a group for this
# product as well. -JMR, 2/16/00
if(Param("makeproductgroups")) {
if (Param("makeproductgroups")) {
# Next we insert into the groups table
SendSQL("INSERT INTO groups " .
"(name, description, isbuggroup, last_changed) " .
@ -390,8 +394,39 @@ if ($action eq 'new') {
PopGlobalSQLState();
}
}
}
# Insert default charting queries for this product.
# If they aren't using charting, this won't do any harm.
GetVersionTable();
my @series;
# We do every status, every resolution, and an "opened" one as well.
foreach my $bug_status (@::legal_bug_status) {
push(@series, [$bug_status, "bug_status=$bug_status"]);
}
foreach my $resolution (@::legal_resolution) {
next if !$resolution;
push(@series, [$resolution, "resolution=$resolution"]);
}
# For localisation reasons, we get the name of the "global" subcategory
# and the title of the "open" query from the submitted form.
my @openedstatuses = ("UNCONFIRMED", "NEW", "ASSIGNED", "REOPENED");
my $query = join("&", map { "bug_status=$_" } @openedstatuses);
push(@series, [$::FORM{'open_name'}, $query]);
foreach my $sdata (@series) {
# We create the series with an nonsensical series_id, which is
# guaranteed not to exist. This is OK, because we immediately call
# createInDatabase().
my $series = new Bugzilla::Series(-1, $product,
$::FORM{'subcategory'},
$sdata->[0], $::userid, 1,
$sdata->[1] . "&product=$product");
$series->createInDatabase();
}
# Make versioncache flush

View File

@ -137,7 +137,9 @@ sub PrefillForm {
"status_whiteboard_type", "bug_id",
"bugidtype", "keywords", "keywords_type",
"x_axis_field", "y_axis_field", "z_axis_field",
"chart_format", "cumulate", "x_labels_vertical")
"chart_format", "cumulate", "x_labels_vertical",
"category", "subcategory", "name", "newcategory",
"newsubcategory", "public", "frequency")
{
# This is a bit of a hack. The default, empty list has
# three entries to accommodate the needs of the email fields -
@ -378,6 +380,11 @@ $vars->{'userdefaultquery'} = $userdefaultquery;
$vars->{'orders'} = \@orders;
$default{'querytype'} = $deforder || 'Importance';
if (($::FORM{'query_format'} || $::FORM{'format'}) eq "create-series") {
require Bugzilla::Chart;
$vars->{'category'} = Bugzilla::Chart::getVisibleSeries();
}
# Add in the defaults.
$vars->{'default'} = \%default;

View File

@ -197,6 +197,36 @@
'bug.delta',
],
'reports/chart.html.tmpl' => [
'width',
'height',
'imageurl',
'sizeurl',
'height + 100',
'height - 100',
'width + 100',
'width - 100',
],
'reports/series-common.html.tmpl' => [
'sel.name',
'sel.accesskey',
'"onchange=\'$sel.onchange\'" IF sel.onchange',
],
'reports/chart.csv.tmpl' => [
'data.$j.$i',
],
'reports/create-chart.html.tmpl' => [
'series.series_id',
'newidx',
],
'reports/edit-series.html.tmpl' => [
'default.series_id',
],
'list/change-columns.html.tmpl' => [
'column',
'field_descs.${column} || column', #
@ -293,6 +323,7 @@
'old_email', # email address
'new_email', # email address
'message_tag',
'series.frequency * 2',
],
'global/select-menu.html.tmpl' => [

View File

@ -132,6 +132,11 @@
[% title = "Invalid Dimensions" %]
The width or height specified is not a positive integer.
[% ELSIF error == "invalid_series_id" %]
[% title = "Invalid Series" %]
The series_id [% series_id FILTER html %] is not valid. It may be that
this series has been deleted.
[% ELSIF error == "mismatched_bug_ids_on_obsolete" %]
Attachment [% attach_id FILTER html %] ([% description FILTER html %])
is attached to bug [% attach_bug_id FILTER html %], but you tried to
@ -178,6 +183,12 @@
[% ELSIF error == "missing_bug_id" %]
No bug ID was given.
[% ELSIF error == "missing_series_id" %]
Having inserted a series into the database, no series_id was returned for
it. Series: [% series.category FILTER html %] /
[%+ series.subcategory FILTER html %] /
[%+ series.name FILTER html %].
[% ELSIF error == "no_y_axis_defined" %]
No Y axis was defined when creating report. The X axis is optional,
but the Y axis is compulsory.

View File

@ -131,6 +131,29 @@
<a href="editflagtypes.cgi">Back to flag types.</a>
</p>
[% ELSIF message_tag == "series_already_exists" %]
[% title = "Series Already Exists" %]
A series <em>[% series.category FILTER html %] /
[%+ series.subcategory FILTER html %] /
[%+ series.name FILTER html %]</em>
already exists. If you want to create this series, you will need to give
it a different name. @@@ subscribe?
<br><br>
Go back or
<a href="query.cgi?format=create-series">create another series</a>.
[% ELSIF message_tag == "series_created" %]
[% title = "Series Created" %]
The series <em>[% series.category FILTER html %] /
[%+ series.subcategory FILTER html %] /
[%+ series.name FILTER html %]</em>
has been created. Note that you may need to wait up to
[% series.frequency * 2 %] days before there will be enough data for a
chart of this series to be produced.
<br><br>
Go back or
<a href="query.cgi?format=create-series">create another series</a>.
[% ELSIF message_tag == "shutdown" %]
[% title = "Bugzilla is Down" %]
[% Param("shutdownhtml") %]

View File

@ -255,7 +255,7 @@
You entered <tt>[% value FILTER html %]</tt>, which isn't.
[% ELSIF error == "illegal_date" %]
[% title = "Your Query Makes No Sense" %]
[% title = "Illegal Date" %]
'<tt>[% date FILTER html %]</tt>' is not a legal date.
[% ELSIF error == "illegal_email_address" %]
@ -266,6 +266,11 @@
It must also not contain any of these special characters:
<tt>\ ( ) &amp; &lt; &gt; , ; : &quot; [ ]</tt>, or any whitespace.
[% ELSIF error == "illegal_frequency" %]
[% title = "Too Frequent" %]
Unless you are an administrator, you may not create series which are
run more often than once every [% minimum FILTER html %] days.
[% ELSIF error == "illegal_group_control_combination" %]
[% title = "Your Group Control Combination Is Illegal" %]
Your group control combination for group &quot;
@ -282,6 +287,18 @@
The name of your query cannot contain any of the following characters:
&lt;, &gt;, &amp;.
[% ELSIF error == "illegal_series_creation" %]
You are not authorised to create series.
[% ELSIF error == "illegal_series_edit" %]
You are not authorised to edit this series. To do this, you must either
be its creator, or an administrator.
[% ELSIF error == "insufficient_data" %]
[% title = "Insufficient Data" %]
None of the series you selected have any data associated with them, so a
chart cannot be plotted.
[% ELSIF error == "insufficient_data_points" %]
We don't have enough data points to make a graph (yet).
@ -352,10 +369,19 @@
if you are going to accept it. Part of accepting
a bug is giving an estimate of when it will be fixed.
[% ELSIF error == "misarranged_dates" %]
[% title = "Misarranged Dates" %]
Your start date ([% datefrom FILTER html %]) is after
your end date ([% dateto FILTER html %]).
[% ELSIF error == "missing_attachment_description" %]
[% title = "Missing Attachment Description" %]
You must enter a description for the attachment.
[% ELSIF error == "missing_category" %]
[% title = "Missing Category" %]
You did not specify a category for this series.
[% ELSIF error == "missing_content_type" %]
[% title = "Missing Content-Type" %]
You asked Bugzilla to auto-detect the content type, but
@ -383,14 +409,26 @@
You must specify one or more fields in which to search for
<tt>[% email FILTER html %]</tt>.
[% ELSIF error == "missing_frequency" %]
[% title = "Missing Frequency" %]
You did not specify a valid frequency for this series.
[% ELSIF error == "missing_name" %]
[% title = "Missing Name" %]
You did not specify a name for this series.
[% ELSIF error == "missing_query" %]
[% title = "Missing Query" %]
The query named <em>[% queryname FILTER html %]</em> does not
exist.
[% ELSIF error == "missing_subcategory" %]
[% title = "Missing Subcategory" %]
You did not specify a subcategory for this series.
[% ELSIF error == "need_component" %]
[% title = "Component Required" %]
You must specify a component to help determine the new owner of these bugs.
You must specify a component to help determine the new owner of these bugs.
[% ELSIF error == "need_numeric_value" %]
[% title = "Numeric Value Required" %]

View File

@ -0,0 +1,40 @@
[%# 1.0@bugzilla.org %]
[%# 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>
#%]
[% data = chart.data %]
Date\Series,
[% FOREACH label = chart.labels %]
[% label FILTER csv %][% "," UNLESS loop.last %]
[% END %]
[%# The data, which is in the correct format for GD, is conceptually the wrong
# way round for CSV output. So, we need to invert it here, which is why
# these loops aren't just plain FOREACH.
#%]
[% i = 0 %]
[% WHILE i < data.0.size %]
[% j = 0 %]
[% WHILE j < data.size %]
[% data.$j.$i %][% "," UNLESS (j == data.size - 1) %]
[% j = j + 1 %]
[% END %]
[% i = i + 1 %]
[% END %]

View File

@ -0,0 +1,66 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[%# INTERFACE:
#%]
[% DEFAULT width = 600
height = 350
%]
[% PROCESS global/header.html.tmpl
title = "Chart"
h3 = time2str("%Y-%m-%d %H:%M:%S", time)
%]
<div align="center">
[% imageurl = BLOCK %]chart.cgi?
[% imagebase FILTER html %]&amp;ctype=png&amp;action=plot&amp;width=
[% width %]&amp;height=[% height -%]
[% END %]
<img alt="Graphical report results" src="[% imageurl %]"
width="[% width %]" height="[% height %]">
<p>
[% sizeurl = BLOCK %]chart.cgi?
[% imagebase FILTER html %]&amp;action=wrap
[% END %]
<a href="[% sizeurl %]&amp;width=[% width %]&amp;height=
[% height + 100 %]">Taller</a><br>
<a href="[% sizeurl %]&amp;width=[% width - 100 %]&amp;height=
[% height %]">Thinner</a> *
<a href="[% sizeurl %]&amp;width=[% width + 100 %]&amp;height=
[% height %]">Fatter</a>&nbsp;&nbsp;&nbsp;&nbsp;<br>
<a href="[% sizeurl %]&amp;width=[% width %]&amp;height=
[% height - 100 %]">Shorter</a><br>
</p>
<p>
<a href="chart.cgi?
[% imagebase FILTER html %]&amp;ctype=csv&amp;action=plot">CSV</a> |
<a href="chart.cgi?[% imagebase FILTER html %]&amp;action=assemble">Edit
this chart</a>
</p>
</div>
[% PROCESS global/footer.html.tmpl %]

View File

@ -0,0 +1,56 @@
[%# 1.0@bugzilla.org %]
[%# 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>
#%]
[% y_label = "Bugs" %]
[% x_label = "Time" %]
[% IF cumulate %]
[% USE graph = GD.Graph.area(width, height) %]
[% graph.set(cumulate => "true") %]
[% ELSE %]
[% USE graph = GD.Graph.lines(width, height) %]
[% END %]
[% FILTER null;
x_label_skip = (30 * chart.data.0.size / width);
graph.set(x_label => x_label,
y_label => y_label,
y_tick_number => 8,
x_label_position => 0.5,
x_labels_vertical => 1,
x_label_skip => x_label_skip,
legend_placement => "RT",
line_width => 2);
# Workaround for the fact that set_legend won't take chart.labels directly,
# because chart.labels is an array reference rather than an array.
graph.set_legend(chart.labels.0, chart.labels.1, chart.labels.2,
chart.labels.3, chart.labels.4, chart.labels.5,
chart.labels.6, chart.labels.7, chart.labels.8,
chart.labels.9, chart.labels.10, chart.labels.11,
chart.labels.12, chart.labels.13, chart.labels.14,
chart.labels.15);
graph.plot(chart.data).png | stdout(1);
END;
-%]

View File

@ -0,0 +1,281 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[%# INTERFACE:
# chart: Chart object representing the currently assembled chart.
# category: hash (keyed by category) of hashes (keyed by subcategory) of
# hashes (keyed by name), with value being the series_id of the
# series. Contains details of all series the user can see.
#%]
[% PROCESS global/header.html.tmpl
title = "Create Chart"
%]
[% PROCESS "reports/series-common.html.tmpl"
donames = 1
%]
<script>
[%# This function takes necessary action on selection of a subcategory %]
function subcatSelected() {
var cat = document.chartform.category.value;
var subcat = document.chartform.subcategory.value;
var names = series[cat][subcat];
var namewidget = document.chartform.name;
namewidget.options.length = 0;
var i = 0;
for (x in names) {
namewidget.options[i] = new Option(x, names[x]);
i++;
}
namewidget.options[0].selected = true;
checkNewState();
}
</script>
[% gttext = "Grand Total" %]
<h3>Current Data Sets:</h3>
<form method="get" action="chart.cgi" name="chartform">
[% IF chart.lines.size > 0 %]
<table border="0" cellspacing="2" cellpadding="2">
<tr>
<th>Select</th>
<th>As</th>
<th></th>
<th>Data Set</th>
<th>Subs</th>
<th></th>
</tr>
[%# The external loop has two counters; one which keeps track of where we
# are in the old labels array, and one which keeps track of the new
# indexes for the form elements. They are different if chart.lines has
# empty slots in it.
#%]
[% labelidx = 0 %]
[% newidx = 0 %]
[% FOREACH line = chart.lines %]
[% IF NOT line %]
[%# chart.lines has an empty slot, so chart.labels will too. We
# increment labelidx only to keep the labels in sync with the data.
#%]
[% labelidx = labelidx + 1 %]
[% NEXT %]
[% END %]
[% FOREACH series = line %]
<tr>
[% IF loop.first %]
<td align="center" rowspan="[% line.size %]">
<input type="checkbox" value="1" name="select[% newidx %]">
</td>
<td rowspan="[% line.size %]">
<input type="text" size="20" name="label[% newidx %]"
value="[% (chart.labels.$labelidx OR series.name)
FILTER html %]">
</td>
[% END %]
<td>
[% "{" IF line.size > 1 %]
</td>
<td>
<a href="buglist.cgi?cmdtype=dorem&amp;namedcmd=
[% series.category FILTER html %]-
[% series.subcategory FILTER html %]-
[% series.name FILTER html -%]&amp;series_id=
[% series.series_id %]&amp;remaction=runseries">
[% series.category FILTER html %] /
[%+ series.subcategory FILTER html %] /
[%+ series.name FILTER html %]
</a>
<input type="hidden" name="line[% newidx %]"
value="[% series.series_id %]">
</td>
<td>
[% IF series.creator != 0 %]
[% IF series.subscribed %]
<input type="submit" value="Unsubscribe" style="width: 12ex;"
name="action-unsubscribe[% series.series_id %]">
[% ELSE %]
<input type="submit" value="Subscribe" style="width: 12ex;"
name="action-subscribe[% series.series_id %]">
[% END %]
[% END %]
</td>
<td align="center">
[% IF user.userid == series.creator OR UserInGroup("admin") %]
<a href="chart.cgi?action=edit&series_id=
[% series.series_id %]">Edit</a>
[% END %]
</td>
</tr>
[% END %]
[% labelidx = labelidx + 1 %]
[% newidx = newidx + 1 %]
[% END %]
[% IF chart.gt %]
<tr>
<td align="center">
<input type="checkbox" value="1" name="select65536">
<input type="hidden" value="1" name="gt">
</td>
<td>
<input type="text" size="20" name="labelgt"
value="[% (chart.labelgt OR gttext) FILTER html %]">
</td>
<td></td>
<td>
<i>[% gttext FILTER html %]</i>
</td>
<td></td>
<td></td>
</tr>
[% END %]
<tr>
<td colspan="6">&nbsp;</td>
</tr>
<tr>
<td valign="bottom" style="text-align: center;">
<input type="submit" name="action-sum" value="Sum"
style="width: 5em;"><br>
<input type="submit" name="action-remove" value="Remove"
style="width: 5em;">
</td>
<td style="text-align: right; vertical-align: bottom;">
<b>Cumulate:</b>
<input type="checkbox" name="cumulate" value="1">
</td>
<td></td>
<td valign="bottom">
<b>Date Range:</b>
<input type="text" size="12" name="datefrom"
value="[% time2str("%Y-%m-%d", chart.datefrom) IF chart.datefrom%]">
<b>to</b>
<input type="text" size="12" name="dateto"
value="[% time2str("%Y-%m-%d", chart.dateto) IF chart.dateto %]">
</td>
<td valign="bottom">
</td>
<td style="text-align: right" valign="bottom">
<input type="submit" name="action-wrap" value="Chart"
style="width: 5em;">
</td>
</tr>
</table>
[% ELSE %]
<p><i>None</i></p>
[% END %]
<h3>Select Data Sets:</h3>
<table cellpadding="2" cellspacing="2" border="0">
[% IF NOT category OR category.size == 0 %]
<tr>
<td>
<i>You do not have permissions to see any data sets, or none
exist.</i>
</td>
</tr>
[% ELSE %]
<tr>
<th>Category:</th>
<noscript><th></th></noscript>
<th>Sub-category:</th>
<noscript><th></th></noscript>
<th>Name:</th>
<th><br>
</th>
</tr>
<tr>
[% PROCESS series_select sel = { name => 'category',
size => 5,
onchange = "catSelected();
subcatSelected();" } %]
<noscript>
<td>
<input type="submit" name="action-assemble" value="Update -->">
</td>
</noscript>
[% PROCESS series_select sel = { name => 'subcategory',
size => 5,
onchange = "subcatSelected()" } %]
<noscript>
<td>
<input type="submit" name="action-assemble" value="Update -->">
</td>
</noscript>
<td align="left">
<label for="name" accesskey="N">
<select name="name" id="name" style="width: 15em"
size="5" multiple="multiple"
[% FOREACH x = name.keys.sort %]
<option value="[% name.$x FILTER html %]"
[%# " selected" IF lsearch(default.name, x) != -1 %]>
[% x FILTER html %]</option>
[% END %]
</select>
</label>
</td>
<td style="text-align: center; vertical-align: middle;">
<input type="submit" name="action-add" value="Add"
style="width: 3em;"><br>
</td>
</tr>
[% END %]
</table>
<script>
document.chartform.category[0].selected = true;
catSelected();
subcatSelected();
</script>
</form>
[% IF UserInGroup('editbugs') %]
<h3><a href="query.cgi?format=create-series">New Data Set</a></h3>
[% END %]
[% PROCESS global/footer.html.tmpl %]

View File

@ -0,0 +1,57 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[% title = "Edit Series" %]
[% h2 = BLOCK %]
[% default.category FILTER html %] /
[%+ default.subcategory FILTER html %] /
[%+ default.name FILTER html %]
[% END %]
[% PROCESS global/header.html.tmpl %]
<form method="get" action="chart.cgi" name="chartform">
[% button_name = "Change" %]
[% PROCESS reports/series.html.tmpl %]
[% IF default.series_id %]
<input type="hidden" name="series_id" value="[% default.series_id %]">
[% END %]
</form>
<p>
<b>Creator</b>: <a href="mailto:[% creator.email FILTER html %]">
[% creator.email FILTER html %]</a>
</p>
<p>
<a href="query.cgi?[% default.query FILTER html%]">View
series search parameters</a> |
<a href="buglist.cgi?cmdtype=dorem&amp;namedcmd=
[% default.category FILTER html %]-
[% default.subcategory FILTER html %]-
[% default.name FILTER html %]&amp;remaction=runseries&amp;series_id=
[% default.series_id %]">Run series search</a>
</p>
[% PROCESS global/footer.html.tmpl %]

View File

@ -58,10 +58,14 @@
<ul>
<li>
<strong><a href="reports.cgi">Charts</a></strong> -
<strong><a href="reports.cgi">Old Charts</a></strong> -
plot the status and/or resolution of bugs against
time, for each product in your database.
</li>
<li>
<strong><a href="chart.cgi">New Charts</a></strong> -
plot any arbitrary search against time. Far more powerful.
</li>
</ul>
[% PROCESS global/footer.html.tmpl %]

View File

@ -0,0 +1,117 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[%# INTERFACE:
# donames: boolean. True if we have a multi-select for names as well as
# categories and subcategories.
# category: hash (keyed by category) of hashes (keyed by subcategory) of
# hashes (keyed by name), with value being the series_id of the
# series. Contains details of all series the user can see.
#%]
[% subcategory = category.${default.category} %]
[% name = subcategory.${default.subcategory} %]
<script>
[%# This structure holds details of the series the user can select from. %]
var series = {
[% FOREACH c = category.keys.sort %]
"[%+ c FILTER js %]" : {
[% FOREACH s = category.$c.keys.sort %]
"[%+ s FILTER js %]" : {
[% IF donames %]
[% FOREACH n = category.$c.$s.keys.sort %]
"[% n FILTER js %]":
[% category.$c.$s.$n FILTER js %][% ", " UNLESS loop.last %]
[% END %]
[% END %]
}[% ", " UNLESS loop.last %]
[% END %]
}[% ", " UNLESS loop.last %]
[% END %]
};
[%# Should attempt to preserve selection across invocations @@@ %]
[%# This function takes necessary action on selection of a category %]
function catSelected() {
var cat = document.chartform.category.value;
var subcats = series[cat];
var subcatwidget = document.chartform.subcategory;
subcatwidget.options.length = 0;
var i = 0;
for (x in subcats) {
subcatwidget.options[i] = new Option(x, x);
i++;
}
[% IF newtext %]
subcatwidget.options[i] = new Option("[% newtext FILTER js %]", "");
[% END %]
subcatwidget.options[0].selected = true;
if (document.chartform.action[1]) {
[%# On the query form, select the right radio button. %]
document.chartform.action[1].checked = true;
}
checkNewState();
}
[%# This function updates the disabled state of the two "new" textboxes %]
function checkNewState() {
var fm = document.chartform;
if (fm.newcategory) {
fm.newcategory.disabled =
(fm.category.value != "" ||
fm.action[1] && fm.action[1].checked == false);
fm.newsubcategory.disabled =
(fm.subcategory.value != "" ||
fm.action[1] && fm.action[1].checked == false);
}
}
</script>
[%###########################################################################%]
[%# Block for SELECT fields - pinched from search/form.html.tmpl #%]
[%###########################################################################%]
[% BLOCK series_select %]
<td align="left">
<label for="[% sel.name %]" accesskey="[% sel.accesskey %]">
<select name="[% sel.name %]" id="[% sel.name %]"
size="[% sel.size %]" style="width: 15em"
[%+ "onchange='$sel.onchange'" IF sel.onchange %]>
[% FOREACH x = ${sel.name}.keys.sort %]
<option value="[% x FILTER html %]"
[% " selected" IF default.${sel.name} == x %]>
[% x FILTER html %]</option>
[% END %]
[% IF newtext %]
<option value="">[% newtext FILTER html %]</option>
[% END %]
</select>
</label>
</td>
[% END %]

View File

@ -0,0 +1,96 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[%# INTERFACE:
# default: hash. Defaults for category, subcategory, name etc.
# button_name: string. What the button will say.
# category: hash (keyed by category) of hashes (keyed by subcategory) of
# hashes (keyed by name), with value being the series_id of the
# series. Contains details of all series the user can see.
#%]
[% PROCESS "reports/series-common.html.tmpl"
newtext = "New (name below)"
%]
<table cellpadding="2" cellspacing="2" border="0"
style="text-align: left; margin-left: 20px">
<tbody>
<tr>
<th>Category:</th>
<noscript><th></th></noscript>
<th>Sub-category:</th>
<th>Name:</th>
<td></td>
</tr>
<tr>
[% PROCESS series_select sel = { name => 'category',
size => 5,
onchange => "catSelected()" } %]
<noscript>
<td>
<input type="submit" name="action-edit" value="Update -->">
</td>
</noscript>
[% PROCESS series_select sel = { name => 'subcategory',
size => 5,
onchange => "checkNewState()" } %]
<td valign="top" name="name">
<input type="text" name="name" maxlength="64"
value="[% default.name.0 FILTER html %]" size="25">
</td>
<td valign="top">
<span style="font-weight: bold;">Run every</span> &nbsp;
<input type="text" size="2" name="frequency"
value="[% (default.frequency.0 OR 7) FILTER html %]">
<span style="font-weight: bold;">&nbsp;day(s)</span><br>
[% IF UserInGroup('admin') %]
<input type="checkbox" name="public"
[% "checked='checked'" IF default.public.0 %]>
<span style="font-weight: bold;">Visible to all</span>
[% END %]
</td>
</tr>
<tr>
<td>
<input type="text" style="width: 100%" name="newcategory"
maxlength="64" value="[% default.newcategory.0 FILTER html %]">
</td>
<noscript><td></td></noscript>
<td>
<input type="text" style="width: 100%" name="newsubcategory"
maxlength="64"
value="[% default.newsubcategory.0 FILTER html %]">
</td>
<td></td>
<td>
<input type="submit" value="[% button_name FILTER html %]">
</td>
</tbody>
</table>
<script>
checkNewState();
</script>

View File

@ -0,0 +1,67 @@
<!-- 1.0@bugzilla.org -->
[%# 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>
#%]
[%# INTERFACE:
# This template has no interface. However, to use it, you need to fulfill
# the interfaces of search/form.html.tmpl, reports/series.html.tmpl and
# search/boolean-charts.html.tmpl.
#%]
[% PROCESS global/header.html.tmpl
title = "Create New Data Set"
onload = "selectProduct(document.forms['chartform']);"
%]
[% button_name = "I'm Feeling Buggy" %]
<form method="get" action="chart.cgi" name="chartform">
[% PROCESS search/form.html.tmpl %]
<table>
<tr>
<td>
<input type="radio" id="action-search"
name="action" value="search" checked="checked">
<label for="action-search">Run this search</label></td>
</tr>
<tr>
<td>
<input type="radio" id="action-create" name="action" value="create">
<label for="action-create">
Start recording bug count data for this search, as follows:
</label>
<br>
[% INCLUDE reports/series.html.tmpl %]
</td>
</tr>
</table>
<hr>
[% PROCESS "search/boolean-charts.html.tmpl" %]
</form>
[% PROCESS global/footer.html.tmpl %]