Chart.pm 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. # -*- Mode: perl; indent-tabs-mode: nil -*-
  2. #
  3. # The contents of this file are subject to the Mozilla Public
  4. # License Version 1.1 (the "License"); you may not use this file
  5. # except in compliance with the License. You may obtain a copy of
  6. # the License at http://www.mozilla.org/MPL/
  7. #
  8. # Software distributed under the License is distributed on an "AS
  9. # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
  10. # implied. See the License for the specific language governing
  11. # rights and limitations under the License.
  12. #
  13. # The Original Code is the Bugzilla Bug Tracking System.
  14. #
  15. # The Initial Developer of the Original Code is Netscape Communications
  16. # Corporation. Portions created by Netscape are
  17. # Copyright (C) 1998 Netscape Communications Corporation. All
  18. # Rights Reserved.
  19. #
  20. # Contributor(s): Gervase Markham <gerv@gerv.net>
  21. # Albert Ting <altlst@sonic.net>
  22. # A. Karl Kornel <karl@kornel.name>
  23. use strict;
  24. # This module represents a chart.
  25. #
  26. # Note that it is perfectly legal for the 'lines' member variable of this
  27. # class (which is an array of Bugzilla::Series objects) to have empty members
  28. # in it. If this is true, the 'labels' array will also have empty members at
  29. # the same points.
  30. package Bugzilla::Chart;
  31. use Bugzilla::Error;
  32. use Bugzilla::Util;
  33. use Bugzilla::Series;
  34. use Date::Format;
  35. use Date::Parse;
  36. use List::Util qw(max);
  37. sub new {
  38. my $invocant = shift;
  39. my $class = ref($invocant) || $invocant;
  40. # Create a ref to an empty hash and bless it
  41. my $self = {};
  42. bless($self, $class);
  43. if ($#_ == 0) {
  44. # Construct from a CGI object.
  45. $self->init($_[0]);
  46. }
  47. else {
  48. die("CGI object not passed in - invalid number of args \($#_\)($_)");
  49. }
  50. return $self;
  51. }
  52. sub init {
  53. my $self = shift;
  54. my $cgi = shift;
  55. # The data structure is a list of lists (lines) of Series objects.
  56. # There is a separate list for the labels.
  57. #
  58. # The URL encoding is:
  59. # line0=67&line0=73&line1=81&line2=67...
  60. # &label0=B+/+R+/+NEW&label1=...
  61. # &select0=1&select3=1...
  62. # &cumulate=1&datefrom=2002-02-03&dateto=2002-04-04&ctype=html...
  63. # &gt=1&labelgt=Grand+Total
  64. foreach my $param ($cgi->param()) {
  65. # Store all the lines
  66. if ($param =~ /^line(\d+)$/) {
  67. foreach my $series_id ($cgi->param($param)) {
  68. detaint_natural($series_id)
  69. || ThrowCodeError("invalid_series_id");
  70. my $series = new Bugzilla::Series($series_id);
  71. push(@{$self->{'lines'}[$1]}, $series) if $series;
  72. }
  73. }
  74. # Store all the labels
  75. if ($param =~ /^label(\d+)$/) {
  76. $self->{'labels'}[$1] = $cgi->param($param);
  77. }
  78. }
  79. # Store the miscellaneous metadata
  80. $self->{'cumulate'} = $cgi->param('cumulate') ? 1 : 0;
  81. $self->{'gt'} = $cgi->param('gt') ? 1 : 0;
  82. $self->{'labelgt'} = $cgi->param('labelgt');
  83. $self->{'datefrom'} = $cgi->param('datefrom');
  84. $self->{'dateto'} = $cgi->param('dateto');
  85. # If we are cumulating, a grand total makes no sense
  86. $self->{'gt'} = 0 if $self->{'cumulate'};
  87. # Make sure the dates are ones we are able to interpret
  88. foreach my $date ('datefrom', 'dateto') {
  89. if ($self->{$date}) {
  90. $self->{$date} = str2time($self->{$date})
  91. || ThrowUserError("illegal_date", { date => $self->{$date}});
  92. }
  93. }
  94. # datefrom can't be after dateto
  95. if ($self->{'datefrom'} && $self->{'dateto'} &&
  96. $self->{'datefrom'} > $self->{'dateto'})
  97. {
  98. ThrowUserError("misarranged_dates",
  99. {'datefrom' => $cgi->param('datefrom'),
  100. 'dateto' => $cgi->param('dateto')});
  101. }
  102. }
  103. # Alter Chart so that the selected series are added to it.
  104. sub add {
  105. my $self = shift;
  106. my @series_ids = @_;
  107. # Get the current size of the series; required for adding Grand Total later
  108. my $current_size = scalar($self->getSeriesIDs());
  109. # Count the number of added series
  110. my $added = 0;
  111. # Create new Series and push them on to the list of lines.
  112. # Note that new lines have no label; the display template is responsible
  113. # for inventing something sensible.
  114. foreach my $series_id (@series_ids) {
  115. my $series = new Bugzilla::Series($series_id);
  116. if ($series) {
  117. push(@{$self->{'lines'}}, [$series]);
  118. push(@{$self->{'labels'}}, "");
  119. $added++;
  120. }
  121. }
  122. # If we are going from < 2 to >= 2 series, add the Grand Total line.
  123. if (!$self->{'gt'}) {
  124. if ($current_size < 2 &&
  125. $current_size + $added >= 2)
  126. {
  127. $self->{'gt'} = 1;
  128. }
  129. }
  130. }
  131. # Alter Chart so that the selections are removed from it.
  132. sub remove {
  133. my $self = shift;
  134. my @line_ids = @_;
  135. foreach my $line_id (@line_ids) {
  136. if ($line_id == 65536) {
  137. # Magic value - delete Grand Total.
  138. $self->{'gt'} = 0;
  139. }
  140. else {
  141. delete($self->{'lines'}->[$line_id]);
  142. delete($self->{'labels'}->[$line_id]);
  143. }
  144. }
  145. }
  146. # Alter Chart so that the selections are summed.
  147. sub sum {
  148. my $self = shift;
  149. my @line_ids = @_;
  150. # We can't add the Grand Total to things.
  151. @line_ids = grep(!/^65536$/, @line_ids);
  152. # We can't add less than two things.
  153. return if scalar(@line_ids) < 2;
  154. my @series;
  155. my $label = "";
  156. my $biggestlength = 0;
  157. # We rescue the Series objects of all the series involved in the sum.
  158. foreach my $line_id (@line_ids) {
  159. my @line = @{$self->{'lines'}->[$line_id]};
  160. foreach my $series (@line) {
  161. push(@series, $series);
  162. }
  163. # We keep the label that labels the line with the most series.
  164. if (scalar(@line) > $biggestlength) {
  165. $biggestlength = scalar(@line);
  166. $label = $self->{'labels'}->[$line_id];
  167. }
  168. }
  169. $self->remove(@line_ids);
  170. push(@{$self->{'lines'}}, \@series);
  171. push(@{$self->{'labels'}}, $label);
  172. }
  173. sub data {
  174. my $self = shift;
  175. $self->{'_data'} ||= $self->readData();
  176. return $self->{'_data'};
  177. }
  178. # Convert the Chart's data into a plottable form in $self->{'_data'}.
  179. sub readData {
  180. my $self = shift;
  181. my @data;
  182. my @maxvals;
  183. # Note: you get a bad image if getSeriesIDs returns nothing
  184. # We need to handle errors better.
  185. my $series_ids = join(",", $self->getSeriesIDs());
  186. return [] unless $series_ids;
  187. # Work out the date boundaries for our data.
  188. my $dbh = Bugzilla->dbh;
  189. # The date used is the one given if it's in a sensible range; otherwise,
  190. # it's the earliest or latest date in the database as appropriate.
  191. my $datefrom = $dbh->selectrow_array("SELECT MIN(series_date) " .
  192. "FROM series_data " .
  193. "WHERE series_id IN ($series_ids)");
  194. $datefrom = str2time($datefrom);
  195. if ($self->{'datefrom'} && $self->{'datefrom'} > $datefrom) {
  196. $datefrom = $self->{'datefrom'};
  197. }
  198. my $dateto = $dbh->selectrow_array("SELECT MAX(series_date) " .
  199. "FROM series_data " .
  200. "WHERE series_id IN ($series_ids)");
  201. $dateto = str2time($dateto);
  202. if ($self->{'dateto'} && $self->{'dateto'} < $dateto) {
  203. $dateto = $self->{'dateto'};
  204. }
  205. # Convert UNIX times back to a date format usable for SQL queries.
  206. my $sql_from = time2str('%Y-%m-%d', $datefrom);
  207. my $sql_to = time2str('%Y-%m-%d', $dateto);
  208. # Prepare the query which retrieves the data for each series
  209. my $query = "SELECT " . $dbh->sql_to_days('series_date') . " - " .
  210. $dbh->sql_to_days('?') . ", series_value " .
  211. "FROM series_data " .
  212. "WHERE series_id = ? " .
  213. "AND series_date >= ?";
  214. if ($dateto) {
  215. $query .= " AND series_date <= ?";
  216. }
  217. my $sth = $dbh->prepare($query);
  218. my $gt_index = $self->{'gt'} ? scalar(@{$self->{'lines'}}) : undef;
  219. my $line_index = 0;
  220. $maxvals[$gt_index] = 0 if $gt_index;
  221. my @datediff_total;
  222. foreach my $line (@{$self->{'lines'}}) {
  223. # Even if we end up with no data, we need an empty arrayref to prevent
  224. # errors in the PNG-generating code
  225. $data[$line_index] = [];
  226. $maxvals[$line_index] = 0;
  227. foreach my $series (@$line) {
  228. # Get the data for this series and add it on
  229. if ($dateto) {
  230. $sth->execute($sql_from, $series->{'series_id'}, $sql_from, $sql_to);
  231. }
  232. else {
  233. $sth->execute($sql_from, $series->{'series_id'}, $sql_from);
  234. }
  235. my $points = $sth->fetchall_arrayref();
  236. foreach my $point (@$points) {
  237. my ($datediff, $value) = @$point;
  238. $data[$line_index][$datediff] ||= 0;
  239. $data[$line_index][$datediff] += $value;
  240. if ($data[$line_index][$datediff] > $maxvals[$line_index]) {
  241. $maxvals[$line_index] = $data[$line_index][$datediff];
  242. }
  243. $datediff_total[$datediff] += $value;
  244. # Add to the grand total, if we are doing that
  245. if ($gt_index) {
  246. $data[$gt_index][$datediff] ||= 0;
  247. $data[$gt_index][$datediff] += $value;
  248. if ($data[$gt_index][$datediff] > $maxvals[$gt_index]) {
  249. $maxvals[$gt_index] = $data[$gt_index][$datediff];
  250. }
  251. }
  252. }
  253. }
  254. # We are done with the series making up this line, go to the next one
  255. $line_index++;
  256. }
  257. # calculate maximum y value
  258. if ($self->{'cumulate'}) {
  259. # Make sure we do not try to take the max of an array with undef values
  260. my @processed_datediff;
  261. while (@datediff_total) {
  262. my $datediff = shift @datediff_total;
  263. push @processed_datediff, $datediff if defined($datediff);
  264. }
  265. $self->{'y_max_value'} = max(@processed_datediff);
  266. }
  267. else {
  268. $self->{'y_max_value'} = max(@maxvals);
  269. }
  270. $self->{'y_max_value'} |= 1; # For log()
  271. # Align the max y value:
  272. # For one- or two-digit numbers, increase y_max_value until divisible by 8
  273. # For larger numbers, see the comments below to figure out what's going on
  274. if ($self->{'y_max_value'} < 100) {
  275. do {
  276. ++$self->{'y_max_value'};
  277. } while ($self->{'y_max_value'} % 8 != 0);
  278. }
  279. else {
  280. # First, get the # of digits in the y_max_value
  281. my $num_digits = 1+int(log($self->{'y_max_value'})/log(10));
  282. # We want to zero out all but the top 2 digits
  283. my $mask_length = $num_digits - 2;
  284. $self->{'y_max_value'} /= 10**$mask_length;
  285. $self->{'y_max_value'} = int($self->{'y_max_value'});
  286. $self->{'y_max_value'} *= 10**$mask_length;
  287. # Add 10^$mask_length to the max value
  288. # Continue to increase until it's divisible by 8 * 10^($mask_length-1)
  289. # (Throwing in the -1 keeps at least the smallest digit at zero)
  290. do {
  291. $self->{'y_max_value'} += 10**$mask_length;
  292. } while ($self->{'y_max_value'} % (8*(10**($mask_length-1))) != 0);
  293. }
  294. # Add the x-axis labels into the data structure
  295. my $date_progression = generateDateProgression($datefrom, $dateto);
  296. unshift(@data, $date_progression);
  297. if ($self->{'gt'}) {
  298. # Add Grand Total to label list
  299. push(@{$self->{'labels'}}, $self->{'labelgt'});
  300. $data[$gt_index] ||= [];
  301. }
  302. return \@data;
  303. }
  304. # Flatten the data structure into a list of series_ids
  305. sub getSeriesIDs {
  306. my $self = shift;
  307. my @series_ids;
  308. foreach my $line (@{$self->{'lines'}}) {
  309. foreach my $series (@$line) {
  310. push(@series_ids, $series->{'series_id'});
  311. }
  312. }
  313. return @series_ids;
  314. }
  315. # Class method to get the data necessary to populate the "select series"
  316. # widgets on various pages.
  317. sub getVisibleSeries {
  318. my %cats;
  319. # List of groups the user is in; use -1 to make sure it's not empty.
  320. my $grouplist = join(", ", (-1, values(%{Bugzilla->user->groups})));
  321. # Get all visible series
  322. my $dbh = Bugzilla->dbh;
  323. my $serieses = $dbh->selectall_arrayref("SELECT cc1.name, cc2.name, " .
  324. "series.name, series.series_id " .
  325. "FROM series " .
  326. "INNER JOIN series_categories AS cc1 " .
  327. " ON series.category = cc1.id " .
  328. "INNER JOIN series_categories AS cc2 " .
  329. " ON series.subcategory = cc2.id " .
  330. "LEFT JOIN category_group_map AS cgm " .
  331. " ON series.category = cgm.category_id " .
  332. " AND cgm.group_id NOT IN($grouplist) " .
  333. "WHERE creator = " . Bugzilla->user->id . " OR " .
  334. " cgm.category_id IS NULL " .
  335. $dbh->sql_group_by('series.series_id', 'cc1.name, cc2.name, ' .
  336. 'series.name'));
  337. foreach my $series (@$serieses) {
  338. my ($cat, $subcat, $name, $series_id) = @$series;
  339. $cats{$cat}{$subcat}{$name} = $series_id;
  340. }
  341. return \%cats;
  342. }
  343. sub generateDateProgression {
  344. my ($datefrom, $dateto) = @_;
  345. my @progression;
  346. $dateto = $dateto || time();
  347. my $oneday = 60 * 60 * 24;
  348. # When the from and to dates are converted by str2time(), you end up with
  349. # a time figure representing midnight at the beginning of that day. We
  350. # adjust the times by 1/3 and 2/3 of a day respectively to prevent
  351. # edge conditions in time2str().
  352. $datefrom += $oneday / 3;
  353. $dateto += (2 * $oneday) / 3;
  354. while ($datefrom < $dateto) {
  355. push (@progression, time2str("%Y-%m-%d", $datefrom));
  356. $datefrom += $oneday;
  357. }
  358. return \@progression;
  359. }
  360. sub dump {
  361. my $self = shift;
  362. # Make sure we've read in our data
  363. my $data = $self->data;
  364. require Data::Dumper;
  365. print "<pre>Bugzilla::Chart object:\n";
  366. print Data::Dumper::Dumper($self);
  367. print "</pre>";
  368. }
  369. 1;