]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 16009 - generic charting. Patch by gerv; r,a=justdave.
authorgerv%gerv.net <>
Thu, 26 Jun 2003 06:22:50 +0000 (06:22 +0000)
committergerv%gerv.net <>
Thu, 26 Jun 2003 06:22:50 +0000 (06:22 +0000)
23 files changed:
Bugzilla/Chart.pm [new file with mode: 0644]
Bugzilla/Series.pm [new file with mode: 0644]
Bugzilla/Template.pm
buglist.cgi
chart.cgi [new file with mode: 0755]
checksetup.pl
collectstats.pl
editcomponents.cgi
editproducts.cgi
query.cgi
template/en/default/filterexceptions.pl
template/en/default/global/code-error.html.tmpl
template/en/default/global/messages.html.tmpl
template/en/default/global/user-error.html.tmpl
template/en/default/reports/chart.csv.tmpl [new file with mode: 0644]
template/en/default/reports/chart.html.tmpl [new file with mode: 0644]
template/en/default/reports/chart.png.tmpl [new file with mode: 0644]
template/en/default/reports/create-chart.html.tmpl [new file with mode: 0644]
template/en/default/reports/edit-series.html.tmpl [new file with mode: 0644]
template/en/default/reports/menu.html.tmpl
template/en/default/reports/series-common.html.tmpl [new file with mode: 0644]
template/en/default/reports/series.html.tmpl [new file with mode: 0644]
template/en/default/search/search-create-series.html.tmpl [new file with mode: 0644]

diff --git a/Bugzilla/Chart.pm b/Bugzilla/Chart.pm
new file mode 100644 (file)
index 0000000..03b5e41
--- /dev/null
@@ -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 (file)
index 0000000..bc11389
--- /dev/null
@@ -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;
index b8307986110d2742b40c082ee57fc649780bfce2..6c3e2161a524bb82b1bcc9cdf000e232f573126c 100644 (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 } = 
index 0f7dda0acbc319f481ae54b83e7a52987d8317cf..c0c13b033c0705eef35a7b8e5eb0628bbee1e497 100755 (executable)
@@ -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);
diff --git a/chart.cgi b/chart.cgi
new file mode 100755 (executable)
index 0000000..ceaecbb
--- /dev/null
+++ b/chart.cgi
@@ -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());
+}
index 4510788637f45a5b3927c5d81792da5ce0b5741f..df785d8328d41b31ad82f7bfa7ee7e086855cf7d 100755 (executable)
@@ -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
 #
 
 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.
 #
index eedeaa35b0d4d27cca833d2b36784085ca1c4611..42f8e682e1cf732a645f037d6549ab965bceb6a0 100755 (executable)
@@ -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");
+}
index 74e0debe8df1eaaa93d160c50d117e1d8b33280e..018c89cdf14f6c109a09cac95100bda3d953232d 100755 (executable)
@@ -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";
 
index 423f028fe45cc1f90d828cb8e73617ae787e516b..55089d9aeffab979861835313f1234e72a713c9c 100755 (executable)
@@ -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
index 2a8051b6b6d1a2c360d6a595dcef507578f64afe..5e623437c7255bc1aa516b4efdb1982d96c045f6 100755 (executable)
--- a/query.cgi
+++ b/query.cgi
@@ -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;
 
index d2abbdadb125a26e0f1128eba6d6680fab5b84c1..6a7217d76f559529f388f5d1f4681281229773e1 100644 (file)
   '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', #
   'old_email', # email address
   'new_email', # email address
   'message_tag', 
+  'series.frequency * 2',
 ],
 
 'global/select-menu.html.tmpl' => [
index 68f046091a247d377dfeb253c3589efb5b0082ba..84a5e3259a77da89fb7e78e703bb6606df7fd233 100644 (file)
     [% 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 
   [% 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.
index 13136d6cfb4b1af9c9839b0d85140567e078c3da..6b9612f54a05d5705a724b686994b80b070adfa9 100644 (file)
       <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") %]
index f626c640b3484fb9088069db6ced28ed4f4cb59d..a057ef96bf62a649d7c1044f6786f832d6f0916e 100644 (file)
     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" %]
     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;
     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).
         
     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
     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" %]
diff --git a/template/en/default/reports/chart.csv.tmpl b/template/en/default/reports/chart.csv.tmpl
new file mode 100644 (file)
index 0000000..83620bf
--- /dev/null
@@ -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 %]  
diff --git a/template/en/default/reports/chart.html.tmpl b/template/en/default/reports/chart.html.tmpl
new file mode 100644 (file)
index 0000000..95d52d7
--- /dev/null
@@ -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 %]
diff --git a/template/en/default/reports/chart.png.tmpl b/template/en/default/reports/chart.png.tmpl
new file mode 100644 (file)
index 0000000..43d4e96
--- /dev/null
@@ -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;
+-%]
+
diff --git a/template/en/default/reports/create-chart.html.tmpl b/template/en/default/reports/create-chart.html.tmpl
new file mode 100644 (file)
index 0000000..fe0b4a7
--- /dev/null
@@ -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 %]
diff --git a/template/en/default/reports/edit-series.html.tmpl b/template/en/default/reports/edit-series.html.tmpl
new file mode 100644 (file)
index 0000000..352e5fa
--- /dev/null
@@ -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 %]
index 4e21bf4d6229da83960752baa32c72619b1a9c3d..f28f1f6971a34adb5c4d37dc8dd69374018fd4db 100644 (file)
 
 <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 %]
diff --git a/template/en/default/reports/series-common.html.tmpl b/template/en/default/reports/series-common.html.tmpl
new file mode 100644 (file)
index 0000000..7fa34c6
--- /dev/null
@@ -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 %]
diff --git a/template/en/default/reports/series.html.tmpl b/template/en/default/reports/series.html.tmpl
new file mode 100644 (file)
index 0000000..a1474a1
--- /dev/null
@@ -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>
diff --git a/template/en/default/search/search-create-series.html.tmpl b/template/en/default/search/search-create-series.html.tmpl
new file mode 100644 (file)
index 0000000..9673a18
--- /dev/null
@@ -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 %]