Rubber-stamped by Brady Eidson.
[webbrowser.git] / BugsSite / whine.pl
blob7d3ff19e361c66ec6fc8f87d5a8e0ac5e0cff4df
1 #!/usr/bin/env perl -wT
2 # -*- Mode: perl; indent-tabs-mode: nil -*-
4 # The contents of this file are subject to the Mozilla Public
5 # License Version 1.1 (the "License"); you may not use this file
6 # except in compliance with the License. You may obtain a copy of
7 # the License at http://www.mozilla.org/MPL/
9 # Software distributed under the License is distributed on an "AS
10 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
11 # implied. See the License for the specific language governing
12 # rights and limitations under the License.
14 # The Original Code is the Bugzilla Bug Tracking System.
16 # The Initial Developer of the Original Code is Netscape Communications
17 # Corporation. Portions created by Netscape are
18 # Copyright (C) 1998 Netscape Communications Corporation. All
19 # Rights Reserved.
21 # Contributor(s): Erik Stambaugh <erik@dasbistro.com>
23 ################################################################################
24 # Script Initialization
25 ################################################################################
27 use strict;
29 use lib qw(. lib);
31 use Bugzilla;
32 use Bugzilla::Constants;
33 use Bugzilla::Search;
34 use Bugzilla::User;
35 use Bugzilla::Mailer;
36 use Bugzilla::Util;
38 # create some handles that we'll need
39 my $template = Bugzilla->template;
40 my $dbh = Bugzilla->dbh;
41 my $sth;
43 # @seen_schedules is a list of all of the schedules that have already been
44 # touched by reset_timer. If reset_timer sees a schedule more than once, it
45 # sets it to NULL so it won't come up again until the next execution of
46 # whine.pl
47 my @seen_schedules = ();
49 # These statement handles should live outside of their functions in order to
50 # allow the database to keep their SQL compiled.
51 my $sth_run_queries =
52 $dbh->prepare("SELECT " .
53 "query_name, title, onemailperbug " .
54 "FROM whine_queries " .
55 "WHERE eventid=? " .
56 "ORDER BY sortkey");
57 my $sth_get_query =
58 $dbh->prepare("SELECT query FROM namedqueries " .
59 "WHERE userid = ? AND name = ?");
61 # get the event that's scheduled with the lowest run_next value
62 my $sth_next_scheduled_event = $dbh->prepare(
63 "SELECT " .
64 " whine_schedules.eventid, " .
65 " whine_events.owner_userid, " .
66 " whine_events.subject, " .
67 " whine_events.body " .
68 "FROM whine_schedules " .
69 "LEFT JOIN whine_events " .
70 " ON whine_events.id = whine_schedules.eventid " .
71 "WHERE run_next <= NOW() " .
72 "ORDER BY run_next " .
73 $dbh->sql_limit(1)
76 # get all pending schedules matching an eventid
77 my $sth_schedules_by_event = $dbh->prepare(
78 "SELECT id, mailto_type, mailto " .
79 "FROM whine_schedules " .
80 "WHERE eventid=? AND run_next <= NOW()"
84 ################################################################################
85 # Main Body Execution
86 ################################################################################
88 # This script needs to check through the database for schedules that have
89 # run_next set to NULL, which means that schedule is new or has been altered.
90 # It then sets it to run immediately if the schedule entry has it running at
91 # an interval like every hour, otherwise to the appropriate day and time.
93 # After that, it looks over each user to see if they have schedules that need
94 # running, then runs those and generates the email messages.
96 # Send whines from the address in the 'mailfrom' Parameter so that all
97 # Bugzilla-originated mail appears to come from a single address.
98 my $fromaddress = Bugzilla->params->{'mailfrom'};
100 # get the current date and time
101 my ($now_sec, $now_minute, $now_hour, $now_day, $now_month, $now_year,
102 $now_weekday) = localtime;
103 # Convert year to two digits
104 $now_year = sprintf("%02d", $now_year % 100);
105 # Convert the month to January being "1" instead of January being "0".
106 $now_month++;
108 my @daysinmonth = qw(0 31 28 31 30 31 30 31 31 30 31 30 31);
109 # Alter February in case of a leap year. This simple way to do it only
110 # applies if you won't be looking at February of next year, which whining
111 # doesn't need to do.
112 if (($now_year % 4 == 0) &&
113 (($now_year % 100 != 0) || ($now_year % 400 == 0))) {
114 $daysinmonth[2] = 29;
117 # run_day can contain either a calendar day (1, 2, 3...), a day of the week
118 # (Mon, Tue, Wed...), a range of days (All, MF), or 'last' for the last day of
119 # the month.
121 # run_time can contain either an hour (0, 1, 2...) or an interval
122 # (60min, 30min, 15min).
124 # We go over each uninitialized schedule record and use its settings to
125 # determine what the next time it runs should be
126 my $sched_h = $dbh->prepare("SELECT id, run_day, run_time " .
127 "FROM whine_schedules " .
128 "WHERE run_next IS NULL" );
129 $sched_h->execute();
130 while (my ($schedule_id, $day, $time) = $sched_h->fetchrow_array) {
131 # fill in some defaults in case they're blank
132 $day ||= '0';
133 $time ||= '0';
135 # If this schedule is supposed to run today, we see if it's supposed to be
136 # run at a particular hour. If so, we set it for that hour, and if not,
137 # it runs at an interval over the course of a day, which means we should
138 # set it to run immediately.
139 if (&check_today($day)) {
140 # Values that are not entirely numeric are intervals, like "30min"
141 if ($time !~ /^\d+$/) {
142 # set it to now
143 $sth = $dbh->prepare( "UPDATE whine_schedules " .
144 "SET run_next=NOW() " .
145 "WHERE id=?");
146 $sth->execute($schedule_id);
148 # A time greater than now means it still has to run today
149 elsif ($time >= $now_hour) {
150 # set it to today + number of hours
151 $sth = $dbh->prepare("UPDATE whine_schedules " .
152 "SET run_next = CURRENT_DATE + " .
153 $dbh->sql_interval('?', 'HOUR') .
154 " WHERE id = ?");
155 $sth->execute($time, $schedule_id);
157 # the target time is less than the current time
158 else { # set it for the next applicable day
159 $day = &get_next_date($day);
160 $sth = $dbh->prepare("UPDATE whine_schedules " .
161 "SET run_next = (CURRENT_DATE + " .
162 $dbh->sql_interval('?', 'DAY') . ") + " .
163 $dbh->sql_interval('?', 'HOUR') .
164 " WHERE id = ?");
165 $sth->execute($day, $time, $schedule_id);
169 # If the schedule is not supposed to run today, we set it to run on the
170 # appropriate date and time
171 else {
172 my $target_date = &get_next_date($day);
173 # If configured for a particular time, set it to that, otherwise
174 # midnight
175 my $target_time = ($time =~ /^\d+$/) ? $time : 0;
177 $sth = $dbh->prepare("UPDATE whine_schedules " .
178 "SET run_next = (CURRENT_DATE + " .
179 $dbh->sql_interval('?', 'DAY') . ") + " .
180 $dbh->sql_interval('?', 'HOUR') .
181 " WHERE id = ?");
182 $sth->execute($target_date, $target_time, $schedule_id);
185 $sched_h->finish();
187 # get_next_event
189 # This function will:
190 # 1. Lock whine_schedules
191 # 2. Grab the most overdue pending schedules on the same event that must run
192 # 3. Update those schedules' run_next value
193 # 4. Unlock the table
194 # 5. Return an event hashref
196 # The event hashref consists of:
197 # eventid - ID of the event
198 # author - user object for the event's creator
199 # users - array of user objects for recipients
200 # subject - Subject line for the email
201 # body - the text inserted above the bug lists
203 sub get_next_event {
204 my $event = {};
206 # Loop until there's something to return
207 until (scalar keys %{$event}) {
209 $dbh->bz_start_transaction();
211 # Get the event ID for the first pending schedule
212 $sth_next_scheduled_event->execute;
213 my $fetched = $sth_next_scheduled_event->fetch;
214 $sth_next_scheduled_event->finish;
215 return undef unless $fetched;
216 my ($eventid, $owner_id, $subject, $body) = @{$fetched};
218 my $owner = Bugzilla::User->new($owner_id);
220 my $whineatothers = $owner->in_group('bz_canusewhineatothers');
222 my %user_objects; # Used for keeping track of who has been added
224 # Get all schedules that match that event ID and are pending
225 $sth_schedules_by_event->execute($eventid);
227 # Add the users from those schedules to the list
228 while (my $row = $sth_schedules_by_event->fetch) {
229 my ($sid, $mailto_type, $mailto) = @{$row};
231 # Only bother doing any work if this user has whine permission
232 if ($owner->in_group('bz_canusewhines')) {
234 if ($mailto_type == MAILTO_USER) {
235 if (not defined $user_objects{$mailto}) {
236 if ($mailto == $owner_id) {
237 $user_objects{$mailto} = $owner;
239 elsif ($whineatothers) {
240 $user_objects{$mailto} = Bugzilla::User->new($mailto);
244 elsif ($mailto_type == MAILTO_GROUP) {
245 my $sth = $dbh->prepare("SELECT name FROM groups " .
246 "WHERE id=?");
247 $sth->execute($mailto);
248 my $groupname = $sth->fetch->[0];
249 my $group_id = Bugzilla::Group::ValidateGroupName(
250 $groupname, $owner);
251 if ($group_id) {
252 my $glist = join(',',
253 @{Bugzilla::User->flatten_group_membership(
254 $group_id)});
255 $sth = $dbh->prepare("SELECT user_id FROM " .
256 "user_group_map " .
257 "WHERE group_id IN ($glist)");
258 $sth->execute();
259 for my $row (@{$sth->fetchall_arrayref}) {
260 if (not defined $user_objects{$row->[0]}) {
261 $user_objects{$row->[0]} =
262 Bugzilla::User->new($row->[0]);
270 reset_timer($sid);
273 $dbh->bz_commit_transaction();
275 # Only set $event if the user is allowed to do whining
276 if ($owner->in_group('bz_canusewhines')) {
277 my @users = values %user_objects;
278 $event = {
279 'eventid' => $eventid,
280 'author' => $owner,
281 'mailto' => \@users,
282 'subject' => $subject,
283 'body' => $body,
287 return $event;
290 # Run the queries for each event
292 # $event:
293 # eventid (the database ID for this event)
294 # author (user object for who created the event)
295 # mailto (array of user objects for mail targets)
296 # subject (subject line for message)
297 # body (text blurb at top of message)
298 while (my $event = get_next_event) {
300 my $eventid = $event->{'eventid'};
302 # We loop for each target user because some of the queries will be using
303 # subjective pronouns
304 $dbh = Bugzilla->switch_to_shadow_db();
305 for my $target (@{$event->{'mailto'}}) {
306 my $args = {
307 'subject' => $event->{'subject'},
308 'body' => $event->{'body'},
309 'eventid' => $event->{'eventid'},
310 'author' => $event->{'author'},
311 'recipient' => $target,
312 'from' => $fromaddress,
315 # run the queries for this schedule
316 my $queries = run_queries($args);
318 # check to make sure there is something to output
319 my $there_are_bugs = 0;
320 for my $query (@{$queries}) {
321 $there_are_bugs = 1 if scalar @{$query->{'bugs'}};
323 next unless $there_are_bugs;
325 $args->{'queries'} = $queries;
327 mail($args);
329 $dbh = Bugzilla->switch_to_main_db();
332 ################################################################################
333 # Functions
334 ################################################################################
336 # The mail and run_queries functions use an anonymous hash ($args) for their
337 # arguments, which are then passed to the templates.
339 # When run_queries is run, $args contains the following fields:
340 # - body Message body defined in event
341 # - from Bugzilla system email address
342 # - queries array of hashes containing:
343 # - bugs: array of hashes mapping fieldnames to values for this bug
344 # - title: text title given to this query in the whine event
345 # - schedule_id integer id of the schedule being run
346 # - subject Subject line for the message
347 # - recipient user object for the recipient
348 # - author user object of the person who created the whine event
350 # In addition, mail adds two more fields to $args:
351 # - alternatives array of hashes defining mime multipart types and contents
352 # - boundary a MIME boundary generated using the process id and time
354 sub mail {
355 my $args = shift;
356 my $addressee = $args->{recipient};
357 # Don't send mail to someone whose bugmail notification is disabled.
358 return if $addressee->email_disabled;
360 my $template = Bugzilla->template_inner($addressee->settings->{'lang'}->{'value'});
361 my $msg = ''; # it's a temporary variable to hold the template output
362 $args->{'alternatives'} ||= [];
364 # put together the different multipart mime segments
366 $template->process("whine/mail.txt.tmpl", $args, \$msg)
367 or die($template->error());
368 push @{$args->{'alternatives'}},
370 'content' => $msg,
371 'type' => 'text/plain',
373 $msg = '';
375 $template->process("whine/mail.html.tmpl", $args, \$msg)
376 or die($template->error());
377 push @{$args->{'alternatives'}},
379 'content' => $msg,
380 'type' => 'text/html',
382 $msg = '';
384 # now produce a ready-to-mail mime-encoded message
386 $args->{'boundary'} = "----------" . $$ . "--" . time() . "-----";
388 $template->process("whine/multipart-mime.txt.tmpl", $args, \$msg)
389 or die($template->error());
391 Bugzilla->template_inner("");
392 MessageToMTA($msg);
394 delete $args->{'boundary'};
395 delete $args->{'alternatives'};
399 # run_queries runs all of the queries associated with a schedule ID, adding
400 # the results to $args or mailing off the template if a query wants individual
401 # messages for each bug
402 sub run_queries {
403 my $args = shift;
405 my $return_queries = [];
407 $sth_run_queries->execute($args->{'eventid'});
408 my @queries = ();
409 for (@{$sth_run_queries->fetchall_arrayref}) {
410 push(@queries,
412 'name' => $_->[0],
413 'title' => $_->[1],
414 'onemailperbug' => $_->[2],
415 'bugs' => [],
420 foreach my $thisquery (@queries) {
421 next unless $thisquery->{'name'}; # named query is blank
423 my $savedquery = get_query($thisquery->{'name'}, $args->{'author'});
424 next unless $savedquery; # silently ignore missing queries
426 # Execute the saved query
427 my @searchfields = (
428 'bugs.bug_id',
429 'bugs.bug_severity',
430 'bugs.priority',
431 'bugs.rep_platform',
432 'bugs.assigned_to',
433 'bugs.bug_status',
434 'bugs.resolution',
435 'bugs.short_desc',
436 'map_assigned_to.login_name',
438 # A new Bugzilla::CGI object needs to be created to allow
439 # Bugzilla::Search to execute a saved query. It's exceedingly weird,
440 # but that's how it works.
441 my $searchparams = new Bugzilla::CGI($savedquery);
442 my $search = new Bugzilla::Search(
443 'fields' => \@searchfields,
444 'params' => $searchparams,
445 'user' => $args->{'recipient'}, # the search runs as the recipient
447 my $sqlquery = $search->getSQL();
448 $sth = $dbh->prepare($sqlquery);
449 $sth->execute;
451 while (my @row = $sth->fetchrow_array) {
452 my $bug = {};
453 for my $field (@searchfields) {
454 my $fieldname = $field;
455 $fieldname =~ s/^bugs\.//; # No need for bugs.whatever
456 $bug->{$fieldname} = shift @row;
459 if ($thisquery->{'onemailperbug'}) {
460 $args->{'queries'} = [
462 'name' => $thisquery->{'name'},
463 'title' => $thisquery->{'title'},
464 'bugs' => [ $bug ],
467 mail($args);
468 delete $args->{'queries'};
470 else { # It belongs in one message with any other lists
471 push @{$thisquery->{'bugs'}}, $bug;
474 if (!$thisquery->{'onemailperbug'} && @{$thisquery->{'bugs'}}) {
475 push @{$return_queries}, $thisquery;
479 return $return_queries;
482 # get_query gets the namedquery. It's similar to LookupNamedQuery (in
483 # buglist.cgi), but doesn't care if a query name really exists or not, since
484 # individual named queries might go away without the whine_queries that point
485 # to them being removed.
486 sub get_query {
487 my ($name, $user) = @_;
488 my $qname = $name;
489 $sth_get_query->execute($user->id, $qname);
490 my $fetched = $sth_get_query->fetch;
491 $sth_get_query->finish;
492 return $fetched ? $fetched->[0] : '';
495 # check_today gets a run day from the schedule and sees if it matches today
496 # a run day value can contain any of:
497 # - a three-letter day of the week
498 # - a number for a day of the month
499 # - 'last' for the last day of the month
500 # - 'All' for every day
501 # - 'MF' for every weekday
503 sub check_today {
504 my $run_day = shift;
506 if (($run_day eq 'MF')
507 && ($now_weekday > 0)
508 && ($now_weekday < 6)) {
509 return 1;
511 elsif (
512 length($run_day) == 3 &&
513 index("SunMonTueWedThuFriSat", $run_day)/3 == $now_weekday) {
514 return 1;
516 elsif (($run_day eq 'All')
517 || (($run_day eq 'last') &&
518 ($now_day == $daysinmonth[$now_month] ))
519 || ($run_day eq $now_day)) {
520 return 1;
522 return 0;
525 # reset_timer sets the next time a whine is supposed to run, assuming it just
526 # ran moments ago. Its only parameter is a schedule ID.
528 # reset_timer does not lock the whine_schedules table. Anything that calls it
529 # should do that itself.
530 sub reset_timer {
531 my $schedule_id = shift;
533 # Schedules may not be executed more than once for each invocation of
534 # whine.pl -- there are legitimate circumstances that can cause this, like
535 # a set of whines that take a very long time to execute, so it's done
536 # quietly.
537 if (grep($_ == $schedule_id, @seen_schedules)) {
538 null_schedule($schedule_id);
539 return;
541 push @seen_schedules, $schedule_id;
543 $sth = $dbh->prepare( "SELECT run_day, run_time FROM whine_schedules " .
544 "WHERE id=?" );
545 $sth->execute($schedule_id);
546 my ($run_day, $run_time) = $sth->fetchrow_array;
548 # It may happen that the run_time field is NULL or blank due to
549 # a bug in editwhines.cgi when this field was initially 0.
550 $run_time ||= 0;
552 my $run_today = 0;
553 my $minute_offset = 0;
555 # If the schedule is to run today, and it runs many times per day,
556 # it shall be set to run immediately.
557 $run_today = &check_today($run_day);
558 if (($run_today) && ($run_time !~ /^\d+$/)) {
559 # The default of 60 catches any bad value
560 my $minute_interval = 60;
561 if ($run_time =~ /^(\d+)min$/i) {
562 $minute_interval = $1;
565 # set the minute offset to the next interval point
566 $minute_offset = $minute_interval - ($now_minute % $minute_interval);
568 elsif (($run_today) && ($run_time > $now_hour)) {
569 # timed event for later today
570 # (This should only happen if, for example, an 11pm scheduled event
571 # didn't happen until after midnight)
572 $minute_offset = (60 * ($run_time - $now_hour)) - $now_minute;
574 else {
575 # it's not something that runs later today.
576 $minute_offset = 0;
578 # Set the target time if it's a specific hour
579 my $target_time = ($run_time =~ /^\d+$/) ? $run_time : 0;
581 my $nextdate = &get_next_date($run_day);
583 $sth = $dbh->prepare("UPDATE whine_schedules " .
584 "SET run_next = (CURRENT_DATE + " .
585 $dbh->sql_interval('?', 'DAY') . ") + " .
586 $dbh->sql_interval('?', 'HOUR') .
587 " WHERE id = ?");
588 $sth->execute($nextdate, $target_time, $schedule_id);
589 return;
592 if ($minute_offset > 0) {
593 # Scheduling is done in terms of whole minutes.
594 my $next_run = $dbh->selectrow_array('SELECT NOW() + ' .
595 $dbh->sql_interval('?', 'MINUTE'),
596 undef, $minute_offset);
597 $next_run = format_time($next_run, "%Y-%m-%d %R");
599 $sth = $dbh->prepare("UPDATE whine_schedules " .
600 "SET run_next = ? WHERE id = ?");
601 $sth->execute($next_run, $schedule_id);
602 } else {
603 # The minute offset is zero or less, which is not supposed to happen.
604 # complain to STDERR
605 null_schedule($schedule_id);
606 print STDERR "Error: bad minute_offset for schedule ID $schedule_id\n";
610 # null_schedule is used to safeguard against infinite loops. Schedules with
611 # run_next set to NULL will not be available to get_next_event until they are
612 # rescheduled, which only happens when whine.pl starts.
613 sub null_schedule {
614 my $schedule_id = shift;
615 $sth = $dbh->prepare("UPDATE whine_schedules " .
616 "SET run_next = NULL " .
617 "WHERE id=?");
618 $sth->execute($schedule_id);
621 # get_next_date determines the difference in days between now and the next
622 # time a schedule should run, excluding today
624 # It takes a run_day argument (see check_today, above, for an explanation),
625 # and returns an integer, representing a number of days.
626 sub get_next_date {
627 my $day = shift;
629 my $add_days = 0;
631 if ($day eq 'All') {
632 $add_days = 1;
634 elsif ($day eq 'last') {
635 # next_date should contain the last day of this month, or next month
636 # if it's today
637 if ($daysinmonth[$now_month] == $now_day) {
638 my $month = $now_month + 1;
639 $month = 1 if $month > 12;
640 $add_days = $daysinmonth[$month] + 1;
642 else {
643 $add_days = $daysinmonth[$now_month] - $now_day;
646 elsif ($day eq 'MF') { # any day Monday through Friday
647 if ($now_weekday < 5) { # Sun-Thurs
648 $add_days = 1;
650 elsif ($now_weekday == 5) { # Friday
651 $add_days = 3;
653 else { # it's 6, Saturday
654 $add_days = 2;
657 elsif ($day !~ /^\d+$/) { # A specific day of the week
658 # The default is used if there is a bad value in the database, in
659 # which case we mark it to a less-popular day (Sunday)
660 my $day_num = 0;
662 if (length($day) == 3) {
663 $day_num = (index("SunMonTueWedThuFriSat", $day)/3) or 0;
666 $add_days = $day_num - $now_weekday;
667 if ($add_days <= 0) { # it's next week
668 $add_days += 7;
671 else { # it's a number, so we set it for that calendar day
672 $add_days = $day - $now_day;
673 # If it's already beyond that day this month, set it to the next one
674 if ($add_days <= 0) {
675 $add_days += $daysinmonth[$now_month];
678 return $add_days;