summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorByron Jones <bjones@mozilla.com>2013-10-29 06:32:25 +0100
committerByron Jones <bjones@mozilla.com>2013-10-29 06:32:25 +0100
commit4c09622b4432820e49795345a747e530e5aa0764 (patch)
tree6fc3203ae7296de1fadadf1fce06d6644c4debfc
parent9b6f5d07a80ff630ed76108e0ca9689b4e2410f5 (diff)
downloadbugzilla-4c09622b4432820e49795345a747e530e5aa0764.tar.gz
bugzilla-4c09622b4432820e49795345a747e530e5aa0764.tar.xz
Bug 892615: Add a 24 hour nag to all requests (review, feedback and need-info) and make them follow-able
-rw-r--r--.htaccess1
-rw-r--r--extensions/RequestNagger/Extension.pm250
-rw-r--r--extensions/RequestNagger/bin/send-request.nags.pl183
-rw-r--r--extensions/RequestNagger/lib/Constants.pm111
-rw-r--r--extensions/RequestNagger/lib/TimeAgo.pm186
-rw-r--r--extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl56
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl19
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl91
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl45
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl15
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl105
-rw-r--r--extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl47
-rw-r--r--extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl14
-rw-r--r--extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl16
-rw-r--r--extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl14
-rw-r--r--extensions/RequestNagger/template/en/default/hook/bug/show-header-end.html.tmpl9
-rw-r--r--extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl11
-rw-r--r--extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl25
-rw-r--r--extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl101
-rw-r--r--extensions/RequestNagger/web/js/requestnagger.js13
-rw-r--r--extensions/RequestNagger/web/style/requestnagger.css42
-rw-r--r--extensions/RequestWhiner/disabled0
-rw-r--r--skins/custom/global.css4
-rw-r--r--template/en/default/account/prefs/settings.html.tmpl9
-rwxr-xr-xuserprefs.cgi2
25 files changed, 1367 insertions, 2 deletions
diff --git a/.htaccess b/.htaccess
index d4ecc514b..4e0a08f57 100644
--- a/.htaccess
+++ b/.htaccess
@@ -43,6 +43,7 @@ Redirect permanent /duplicates.html https://bugzilla.mozilla.org/duplicates.cgi
RewriteEngine On
RewriteRule ^review(.*) page.cgi?id=splinter.html$1 [QSA]
RewriteRule ^user_?profile(.*) page.cgi?id=user_profile.html$1 [QSA]
+RewriteRule ^request_defer(.*) page.cgi?id=request_defer.html$1 [QSA]
RewriteRule ^favicon\.ico$ extensions/BMO/web/images/favicon.ico
RewriteRule ^form[\.:]itrequest$ enter_bug.cgi?product=Infrastructure+\%26+Operations&format=itrequest
RewriteRule ^form[\.:](mozlist|poweredby|presentation|trademark|recoverykey)$ enter_bug.cgi?product=mozilla.org&format=$1
diff --git a/extensions/RequestNagger/Extension.pm b/extensions/RequestNagger/Extension.pm
index a8dc4a5c2..af9eb1783 100644
--- a/extensions/RequestNagger/Extension.pm
+++ b/extensions/RequestNagger/Extension.pm
@@ -13,9 +13,244 @@ use warnings;
use base qw(Bugzilla::Extension);
use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Extension::RequestNagger::TimeAgo qw(time_ago);
+use Bugzilla::Flag;
+use Bugzilla::Install::Filesystem;
+use Bugzilla::User::Setting;
+use Bugzilla::Util qw(datetime_from detaint_natural);
+use DateTime;
our $VERSION = '1';
+BEGIN {
+ *Bugzilla::Flag::age = \&_flag_age;
+ *Bugzilla::Flag::deferred = \&_flag_deferred;
+ *Bugzilla::Product::nag_interval = \&_product_nag_interval;
+}
+
+sub _flag_age {
+ return time_ago(datetime_from($_[0]->modification_date));
+}
+
+sub _flag_deferred {
+ my ($self) = @_;
+ if (!exists $self->{deferred}) {
+ my $dbh = Bugzilla->dbh;
+ my ($defer_until) = $dbh->selectrow_array(
+ "SELECT defer_until FROM nag_defer WHERE flag_id=?",
+ undef,
+ $self->id
+ );
+ $self->{deferred} = $defer_until ? datetime_from($defer_until) : undef;
+ }
+ return $self->{deferred};
+}
+
+sub _product_nag_interval { $_[0]->{nag_interval} }
+
+sub object_columns {
+ my ($self, $args) = @_;
+ my ($class, $columns) = @$args{qw(class columns)};
+ if ($class->isa('Bugzilla::Product')) {
+ push @$columns, 'nag_interval';
+ }
+}
+
+sub object_update_columns {
+ my ($self, $args) = @_;
+ my ($object, $columns) = @$args{qw(object columns)};
+ if ($object->isa('Bugzilla::Product')) {
+ push @$columns, 'nag_interval';
+ }
+}
+
+sub object_before_create {
+ my ($self, $args) = @_;
+ my ($class, $params) = @$args{qw(class params)};
+ return unless $class->isa('Bugzilla::Product');
+ my $interval = _check_nag_interval(Bugzilla->cgi->param('nag_interval'));
+ $params->{nag_interval} = $interval;
+}
+
+sub object_end_of_set_all {
+ my ($self, $args) = @_;
+ my ($object, $params) = @$args{qw(object params)};
+ return unless $object->isa('Bugzilla::Product');
+ my $interval = _check_nag_interval(Bugzilla->cgi->param('nag_interval'));
+ $object->set('nag_interval', $interval);
+}
+
+sub _check_nag_interval {
+ my ($value) = @_;
+ detaint_natural($value)
+ || ThrowUserError('invalid_parameter', { name => 'request reminding interval', err => 'must be numeric' });
+ return $value < 0 ? 0 : $value * 24;
+}
+
+sub page_before_template {
+ my ($self, $args) = @_;
+ my ($vars, $page) = @$args{qw(vars page_id)};
+ return unless $page eq 'request_defer.html';
+
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ my $input = Bugzilla->input_params;
+
+ # load flag
+ my $flag_id = scalar($input->{flag})
+ || ThrowUserError('request_nagging_flag_invalid');
+ detaint_natural($flag_id)
+ || ThrowUserError('request_nagging_flag_invalid');
+ my $flag = Bugzilla::Flag->new({ id => $flag_id, cache => 1 })
+ || ThrowUserError('request_nagging_flag_invalid');
+
+ # you can only defer flags directed at you
+ $user->can_see_bug($flag->bug->id)
+ || ThrowUserError("bug_access_denied", { bug_id => $flag->bug->id });
+ $flag->status eq '?'
+ || ThrowUserError('request_nagging_flag_set');
+ $flag->requestee
+ || ThrowUserError('request_nagging_flag_wind');
+ $flag->requestee->id == $user->id
+ || ThrowUserError('request_nagging_flag_not_owned');
+
+ my $date = DateTime->now()->truncate(to => 'day');
+ my $defer_until;
+ if ($input->{'defer-until'}
+ && $input->{'defer-until'} =~ /^(\d\d\d\d)-(\d\d)-(\d\d)$/)
+ {
+ $defer_until = DateTime->new(year => $1, month => $2, day => $3);
+ if ($defer_until > $date->clone->add(days => 7)) {
+ $defer_until = undef;
+ }
+ }
+
+ if ($input->{save} && $defer_until) {
+ $self->_defer_until($flag_id, $defer_until);
+ $vars->{saved} = "1";
+ $vars->{defer_until} = $defer_until;
+ }
+ else {
+ my @dates;
+ foreach my $i (1..7) {
+ $date->add(days => 1);
+ unshift @dates, { days => $i, date => $date->clone };
+ }
+ $vars->{defer_until} = \@dates;
+ }
+
+ $vars->{flag} = $flag;
+}
+
+sub _defer_until {
+ my ($self, $flag_id, $defer_until) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ $dbh->bz_start_transaction();
+
+ my ($defer_id) = $dbh->selectrow_array("SELECT id FROM nag_defer WHERE flag_id=?", undef, $flag_id);
+ if ($defer_id) {
+ $dbh->do("UPDATE nag_defer SET defer_until=? WHERE id=?", undef, $defer_until->ymd, $flag_id);
+ } else {
+ $dbh->do("INSERT INTO nag_defer(flag_id, defer_until) VALUES (?, ?)", undef, $flag_id, $defer_until->ymd);
+ }
+
+ $dbh->bz_commit_transaction();
+}
+
+#
+# hooks
+#
+
+sub object_end_of_update {
+ my ($self, $args) = @_;
+ if ($args->{object}->isa("Bugzilla::Flag") && exists $args->{changes}) {
+ # any change to the flag (setting, clearing, or retargetting) will clear the deferals
+ my $flag = $args->{object};
+ Bugzilla->dbh->do("DELETE FROM nag_defer WHERE flag_id=?", undef, $flag->id);
+ }
+}
+
+sub user_preferences {
+ my ($self, $args) = @_;
+ my $tab = $args->{'current_tab'};
+ return unless $tab eq 'request_nagging';
+
+ my $save = $args->{'save_changes'};
+ my $vars = $args->{'vars'};
+ my $user = Bugzilla->user;
+ my $dbh = Bugzilla->dbh;
+
+ my %watching =
+ map { $_ => 1 }
+ @{ $dbh->selectcol_arrayref(
+ "SELECT profiles.login_name
+ FROM nag_watch
+ INNER JOIN profiles ON nag_watch.nagged_id = profiles.userid
+ WHERE nag_watch.watcher_id = ?
+ ORDER BY profiles.login_name",
+ undef,
+ $user->id
+ ) };
+
+ if ($save) {
+ my $input = Bugzilla->input_params;
+ Bugzilla::User::match_field({ 'add_watching' => {'type' => 'multi'} });
+
+ $dbh->bz_start_transaction();
+
+ # user preference
+ if (my $value = $input->{request_nagging}) {
+ my $settings = $user->settings;
+ my $setting = new Bugzilla::User::Setting('request_nagging');
+ if ($value eq 'default') {
+ $settings->{request_nagging}->reset_to_default;
+ }
+ else {
+ $setting->validate_value($value);
+ $settings->{request_nagging}->set($value);
+ }
+ }
+
+ # watching
+ if ($input->{remove_watched_users}) {
+ my $del_watching = ref($input->{del_watching}) ? $input->{del_watching} : [ $input->{del_watching} ];
+ foreach my $login (@$del_watching) {
+ my $u = Bugzilla::User->new({ name => $login, cache => 1 })
+ || next;
+ next unless exists $watching{$u->login};
+ $dbh->do(
+ "DELETE FROM nag_watch WHERE watcher_id=? AND nagged_id=?",
+ undef,
+ $user->id, $u->id
+ );
+ delete $watching{$u->login};
+ }
+ }
+ if ($input->{add_watching}) {
+ my $add_watching = ref($input->{add_watching}) ? $input->{add_watching} : [ $input->{add_watching} ];
+ foreach my $login (@$add_watching) {
+ my $u = Bugzilla::User->new({ name => $login, cache => 1 })
+ || next;
+ next if exists $watching{$u->login};
+ $dbh->do(
+ "INSERT INTO nag_watch(watcher_id, nagged_id) VALUES(?, ?)",
+ undef,
+ $user->id, $u->id
+ );
+ $watching{$u->login} = 1;
+ }
+ }
+
+ $dbh->bz_commit_transaction();
+ }
+
+ $vars->{watching} = [ sort keys %watching ];
+
+ my $handled = $args->{'handled'};
+ $$handled = 1;
+}
+
#
# installation
#
@@ -90,4 +325,19 @@ sub install_update_db {
$dbh->bz_add_column('products', 'nag_interval', { TYPE => 'INT2', NOTNULL => 1, DEFAULT => 7 * 24 });
}
+sub install_filesystem {
+ my ($self, $args) = @_;
+ my $files = $args->{'files'};
+ my $extensions_dir = bz_locations()->{'extensionsdir'};
+ my $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/send-request-nags.pl";
+ $files->{$script_name} = {
+ perms => Bugzilla::Install::Filesystem::WS_EXECUTE
+ };
+}
+
+sub install_before_final_checks {
+ my ($self, $args) = @_;
+ add_setting('request_nagging', ['on', 'off'], 'on');
+}
+
__PACKAGE__->NAME;
diff --git a/extensions/RequestNagger/bin/send-request.nags.pl b/extensions/RequestNagger/bin/send-request.nags.pl
new file mode 100644
index 000000000..c62d91f03
--- /dev/null
+++ b/extensions/RequestNagger/bin/send-request.nags.pl
@@ -0,0 +1,183 @@
+#!/usr/bin/perl
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+use warnings;
+
+use FindBin qw($RealBin);
+use lib "$RealBin/../../..";
+
+use Bugzilla;
+BEGIN { Bugzilla->extensions() }
+
+use Bugzilla::Attachment;
+use Bugzilla::Bug;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Extension::RequestNagger::Constants;
+use Bugzilla::Mailer;
+use Bugzilla::User;
+use Bugzilla::Util qw(format_time);
+use Email::MIME;
+use Sys::Hostname;
+
+Bugzilla->usage_mode(USAGE_MODE_CMDLINE);
+
+my $DO_NOT_NAG = grep { $_ eq '-d' } @ARGV;
+
+my $dbh = Bugzilla->dbh;
+my $date = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+$date = format_time($date, '%a, %d %b %Y %T %z', 'UTC');
+
+# delete expired defers
+$dbh->do("DELETE FROM nag_defer WHERE defer_until <= CURRENT_DATE()");
+Bugzilla->switch_to_shadow_db();
+
+# send nags to requestees
+send_nags(
+ sql => REQUESTEE_NAG_SQL,
+ template => 'requestee',
+ recipient_field => 'requestee_id',
+ date => $date,
+);
+
+# send nags to watchers
+send_nags(
+ sql => WATCHING_NAG_SQL,
+ template => 'watching',
+ recipient_field => 'watcher_id',
+ date => $date,
+);
+
+sub send_nags {
+ my (%args) = @_;
+ my $rows = $dbh->selectall_arrayref($args{sql}, { Slice => {} });
+
+ # iterate over rows, sending email when the current recipient changes
+ my $requests = [];
+ my $current_recipient;
+ foreach my $request (@$rows) {
+ # send previous user's requests
+ if (!$current_recipient || $request->{$args{recipient_field}} != $current_recipient->id) {
+ send_email(%args, recipient => $current_recipient, requests => $requests);
+ $current_recipient = Bugzilla::User->new({ id => $request->{$args{recipient_field}}, cache => 1 });
+ $requests = [];
+ }
+
+ # check group membership
+ $request->{requestee} = Bugzilla::User->new({ id => $request->{requestee_id}, cache => 1 });
+ my $group;
+ foreach my $type (FLAG_TYPES) {
+ next unless $type->{type} eq $request->{flag_type};
+ $group = $type->{group};
+ last;
+ }
+ next unless $request->{requestee}->in_group($group);
+
+ # check bug visibility
+ next unless $current_recipient->can_see_bug($request->{bug_id});
+
+ # create objects
+ $request->{bug} = Bugzilla::Bug->new({ id => $request->{bug_id}, cache => 1 });
+ $request->{requester} = Bugzilla::User->new({ id => $request->{requester_id}, cache => 1 });
+ $request->{flag} = Bugzilla::Flag->new({ id => $request->{flag_id}, cache => 1 });
+ if ($request->{attach_id}) {
+ $request->{attachment} = Bugzilla::Attachment->new({ id => $request->{attach_id}, cache => 1 });
+ # check attachment visibility
+ next if $request->{attachment}->isprivate && !$current_recipient->is_insider;
+ }
+ if (exists $request->{watcher_id}) {
+ $request->{watcher} = Bugzilla::User->new({ id => $request->{watcher_id}, cache => 1 });
+ }
+
+ # add this request to the current user's list
+ push(@$requests, $request);
+ }
+ send_email(%args, recipient => $current_recipient, requests => $requests);
+}
+
+sub send_email {
+ my (%vars) = @_;
+ my $vars = \%vars;
+ return unless $vars->{recipient} && @{ $vars->{requests} };
+
+ # restructure the list to group by requestee then flag type
+ my $request_list = delete $vars->{requests};
+ my $requests = {};
+ my %seen_types;
+ foreach my $request (@{ $request_list }) {
+ # by requestee
+ my $requestee_login = $request->{requestee}->login;
+ $requests->{$requestee_login} ||= {
+ requestee => $request->{requestee},
+ types => {},
+ typelist => [],
+ };
+
+ # by flag type
+ my $types = $requests->{$requestee_login}->{types};
+ my $flag_type = $request->{flag_type};
+ $types->{$flag_type} ||= [];
+
+ push @{ $types->{$flag_type} }, $request;
+ $seen_types{$requestee_login}{$flag_type} = 1;
+ }
+ foreach my $requestee_login (keys %seen_types) {
+ my @flag_types;
+ foreach my $flag_type (map { $_->{type} } FLAG_TYPES) {
+ push @flag_types, $flag_type if $seen_types{$requestee_login}{$flag_type};
+ }
+ $requests->{$requestee_login}->{typelist} = \@flag_types;
+ }
+ $vars->{requests} = $requests;
+
+ # generate email
+ my $template = Bugzilla->template_inner($vars->{recipient}->setting('lang'));
+ my $template_file = $vars->{template};
+
+ my ($header, $text);
+ $template->process("email/request_nagging-$template_file-header.txt.tmpl", $vars, \$header)
+ || ThrowTemplateError($template->error());
+ $header .= "\n";
+ $template->process("email/request_nagging-$template_file.txt.tmpl", $vars, \$text)
+ || ThrowTemplateError($template->error());
+
+ my @parts = (
+ Email::MIME->create(
+ attributes => { content_type => "text/plain" },
+ body => $text,
+ )
+ );
+ if ($vars->{recipient}->setting('email_format') eq 'html') {
+ my $html;
+ $template->process("email/request_nagging-$template_file.html.tmpl", $vars, \$html)
+ || ThrowTemplateError($template->error());
+ push @parts, Email::MIME->create(
+ attributes => { content_type => "text/html" },
+ body => $html,
+ );
+ }
+
+ my $email = Email::MIME->new($header);
+ $email->header_set('X-Generated-By' => hostname());
+ if (scalar(@parts) == 1) {
+ $email->content_type_set($parts[0]->content_type);
+ } else {
+ $email->content_type_set('multipart/alternative');
+ }
+ $email->parts_set(\@parts);
+
+ # send
+ if ($DO_NOT_NAG) {
+ print $email->as_string, "\n";
+ } else {
+ MessageToMTA($email);
+ }
+}
+
diff --git a/extensions/RequestNagger/lib/Constants.pm b/extensions/RequestNagger/lib/Constants.pm
new file mode 100644
index 000000000..ff31b94e0
--- /dev/null
+++ b/extensions/RequestNagger/lib/Constants.pm
@@ -0,0 +1,111 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Extension::RequestNagger::Constants;
+
+use strict;
+use base qw(Exporter);
+
+our @EXPORT = qw(
+ FLAG_TYPES
+ REQUESTEE_NAG_SQL
+ WATCHING_NAG_SQL
+);
+
+# the order of this array determines the order used in email
+use constant FLAG_TYPES => (
+ {
+ type => 'review', # flag_type.name
+ group => 'everyone', # the user must be a member of this group to receive reminders
+ },
+ {
+ type => 'feedback',
+ group => 'everyone',
+ },
+ {
+ type => 'needinfo',
+ group => 'editbugs',
+ },
+);
+
+sub REQUESTEE_NAG_SQL {
+ my $dbh = Bugzilla->dbh;
+ my @flag_types_sql = map { $dbh->quote($_->{type}) } FLAG_TYPES;
+
+ return "
+ SELECT
+ flagtypes.name AS flag_type,
+ flags.id AS flag_id,
+ flags.bug_id,
+ flags.attach_id,
+ flags.modification_date,
+ requester.userid AS requester_id,
+ requestee.userid AS requestee_id
+ FROM
+ flags
+ INNER JOIN flagtypes ON flagtypes.id = flags.type_id
+ INNER JOIN profiles AS requester ON requester.userid = flags.setter_id
+ INNER JOIN profiles AS requestee ON requestee.userid = flags.requestee_id
+ INNER JOIN bugs ON bugs.bug_id = flags.bug_id
+ INNER JOIN products ON products.id = bugs.product_id
+ LEFT JOIN attachments ON attachments.attach_id = flags.attach_id
+ LEFT JOIN profile_setting ON profile_setting.setting_name = 'request_nagging'
+ LEFT JOIN nag_defer ON nag_defer.flag_id = flags.id
+ WHERE
+ " . $dbh->sql_in('flagtypes.name', \@flag_types_sql) . "
+ AND flags.status = '?'
+ AND products.nag_interval != 0
+ AND TIMESTAMPDIFF(HOUR, flags.modification_date, CURRENT_DATE()) >= products.nag_interval
+ AND (profile_setting.setting_value IS NULL OR profile_setting.setting_value = 'on')
+ AND requestee.disable_mail = 0
+ AND nag_defer.id IS NULL
+ ORDER BY
+ flags.requestee_id,
+ flagtypes.name,
+ flags.modification_date
+ ";
+}
+
+sub WATCHING_NAG_SQL {
+ my $dbh = Bugzilla->dbh;
+ my @flag_types_sql = map { $dbh->quote($_->{type}) } FLAG_TYPES;
+
+ return "
+ SELECT
+ nag_watch.watcher_id,
+ flagtypes.name AS flag_type,
+ flags.id AS flag_id,
+ flags.bug_id,
+ flags.attach_id,
+ flags.modification_date,
+ requester.userid AS requester_id,
+ requestee.userid AS requestee_id
+ FROM
+ flags
+ INNER JOIN flagtypes ON flagtypes.id = flags.type_id
+ INNER JOIN profiles AS requester ON requester.userid = flags.setter_id
+ INNER JOIN profiles AS requestee ON requestee.userid = flags.requestee_id
+ INNER JOIN bugs ON bugs.bug_id = flags.bug_id
+ INNER JOIN products ON products.id = bugs.product_id
+ LEFT JOIN attachments ON attachments.attach_id = flags.attach_id
+ LEFT JOIN nag_defer ON nag_defer.flag_id = flags.id
+ INNER JOIN nag_watch ON nag_watch.nagged_id = flags.requestee_id
+ INNER JOIN profiles AS watcher ON watcher.userid = nag_watch.watcher_id
+ WHERE
+ " . $dbh->sql_in('flagtypes.name', \@flag_types_sql) . "
+ AND flags.status = '?'
+ AND products.nag_interval != 0
+ AND TIMESTAMPDIFF(HOUR, flags.modification_date, CURRENT_DATE()) >= products.nag_interval
+ AND watcher.disable_mail = 0
+ ORDER BY
+ nag_watch.watcher_id,
+ flags.requestee_id,
+ flags.modification_date
+ ";
+}
+
+1;
diff --git a/extensions/RequestNagger/lib/TimeAgo.pm b/extensions/RequestNagger/lib/TimeAgo.pm
new file mode 100644
index 000000000..3dfbbeaac
--- /dev/null
+++ b/extensions/RequestNagger/lib/TimeAgo.pm
@@ -0,0 +1,186 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Extension::RequestNagger::TimeAgo;
+
+use strict;
+use utf8;
+use DateTime;
+use Carp;
+use Exporter qw(import);
+
+use if $ENV{ARCH_64BIT}, 'integer';
+
+our @EXPORT_OK = qw(time_ago);
+
+our $VERSION = '0.06';
+
+my @ranges = (
+ [ -1, 'in the future' ],
+ [ 60, 'just now' ],
+ [ 900, 'a few minutes ago'], # 15*60
+ [ 3000, 'less than an hour ago'], # 50*60
+ [ 4500, 'about an hour ago'], # 75*60
+ [ 7200, 'more than an hour ago'], # 2*60*60
+ [ 21600, 'several hours ago'], # 6*60*60
+ [ 86400, 'today', sub { # 24*60*60
+ my $time = shift;
+ my $now = shift;
+ if ( $time->day < $now->day
+ or $time->month < $now->month
+ or $time->year < $now->year
+ ) {
+ return 'yesterday'
+ }
+ if ($time->hour < 5) {
+ return 'tonight'
+ }
+ if ($time->hour < 10) {
+ return 'this morning'
+ }
+ if ($time->hour < 15) {
+ return 'today'
+ }
+ if ($time->hour < 19) {
+ return 'this afternoon'
+ }
+ return 'this evening'
+ }],
+ [ 172800, 'yesterday'], # 2*24*60*60
+ [ 604800, 'this week'], # 7*24*60*60
+ [ 1209600, 'last week'], # 2*7*24*60*60
+ [ 2678400, 'this month', sub { # 31*24*60*60
+ my $time = shift;
+ my $now = shift;
+ if ($time->year == $now->year and $time->month == $now->month) {
+ return 'this month'
+ }
+ return 'last month'
+ }],
+ [ 5356800, 'last month'], # 2*31*24*60*60
+ [ 24105600, 'several months ago'], # 9*31*24*60*60
+ [ 31536000, 'about a year ago'], # 365*24*60*60
+ [ 34214400, 'last year'], # (365+31)*24*60*60
+ [ 63072000, 'more than a year ago'], # 2*365*24*60*60
+ [ 283824000, 'several years ago'], # 9*365*24*60*60
+ [ 315360000, 'about a decade ago'], # 10*365*24*60*60
+ [ 630720000, 'last decade'], # 20*365*24*60*60
+ [ 2838240000, 'several decades ago'], # 90*365*24*60*60
+ [ 3153600000, 'about a century ago'], # 100*365*24*60*60
+ [ 6307200000, 'last century'], # 200*365*24*60*60
+ [ 6622560000, 'more than a century ago'], # 210*365*24*60*60
+ [ 28382400000, 'several centuries ago'], # 900*365*24*60*60
+ [ 31536000000, 'about a millenium ago'], # 1000*365*24*60*60
+ [ 63072000000, 'more than a millenium ago'], # 2000*365*24*60*60
+);
+
+sub time_ago {
+ my ($time, $now) = @_;
+
+ if (not defined $time or not $time->isa('DateTime')) {
+ croak('DateTime::Duration::Fuzzy::time_ago needs a DateTime object as first parameter')
+ }
+ if (not defined $now) {
+ $now = DateTime->now();
+ }
+ if (not $now->isa('DateTime')) {
+ croak('Invalid second parameter provided to DateTime::Duration::Fuzzy::time_ago; it must be a DateTime object if provided')
+ }
+
+ my $dur = $now->subtract_datetime_absolute($time)->in_units('seconds');
+
+ foreach my $range ( @ranges ) {
+ if ( $dur <= $range->[0] ) {
+ if ( $range->[2] ) {
+ return $range->[2]->($time, $now)
+ }
+ return $range->[1]
+ }
+ }
+
+ return 'millenia ago'
+}
+
+1
+
+__END__
+
+=head1 NAME
+
+DateTime::Duration::Fuzzy -- express dates as fuzzy human-friendly strings
+
+=head1 SYNOPSIS
+
+ use DateTime::Duration::Fuzzy qw(time_ago);
+ use DateTime;
+
+ my $now = DateTime->new(
+ year => 2010, month => 12, day => 12,
+ hour => 19, minute => 59,
+ );
+ my $then = DateTime->new(
+ year => 2010, month => 12, day => 12,
+ hour => 15,
+ );
+ print time_ago($then, $now);
+ # outputs 'several hours ago'
+
+ print time_ago($then);
+ # $now taken from C<time> function
+
+=head1 DESCRIPTION
+
+DateTime::Duration::Fuzzy is inspired from the timeAgo jQuery module
+L<http://timeago.yarp.com/>.
+
+It takes two DateTime objects -- first one representing a moment in the past
+and second optional one representine the present, and returns a human-friendly
+fuzzy expression of the time gone.
+
+=head2 functions
+
+=over 4
+
+=item time_ago($then, $now)
+
+The only exportable function.
+
+First obligatory parameter is a DateTime object.
+
+Second optional parameter is also a DateTime object.
+If it's not provided, then I<now> as the C<time> function returns is
+substituted.
+
+Returns a string expression of the interval between the two DateTime
+objects, like C<several hours ago>, C<yesterday> or <last century>.
+
+=back
+
+=head2 performance
+
+On 64bit machines, it is asvisable to 'use integer', which makes
+the calculations faster. You can turn this on by setting the
+C<ARCH_64BIT> environmental variable to a true value.
+
+If you do this on a 32bit machine, you will get wrong results for
+intervals starting with "several decades ago".
+
+=head1 AUTHOR
+
+Jan Oldrich Kruza, C<< <sixtease at cpan.org> >>
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2010 Jan Oldrich Kruza.
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of either: the GNU General Public License as published
+by the Free Software Foundation; or the Artistic License.
+
+See http://dev.perl.org/licenses/ for more information.
+
+=cut
diff --git a/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl b/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl
new file mode 100644
index 000000000..98317e328
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl
@@ -0,0 +1,56 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+<label for="request_nagging">
+ Send me reminders for outstanding requests:
+</label>
+<select name="request_nagging" id="request_nagging">
+ <option value="default" [% "selected" IF user.settings.request_nagging.is_default %]>
+ Site Default (On)
+ </option>
+ <option value="on" [% "selected" IF !user.settings.request_nagging.is_default
+ && user.settings.request_nagging.value == "on" %]>
+ On
+ </option>
+ <option value="off" [% "selected" IF !user.settings.request_nagging.is_default
+ && user.settings.request_nagging.value == "off" %]>
+ Off
+ </option>
+</select>
+
+<h4>User Request Reminder Watching</h4>
+
+<p>
+ If you watch a user, you will receive a report of their outstanding
+ requests.
+</p>
+
+<p>
+ [% IF watching.size %]
+ You are watching everyone in the following list:<br>
+ <select id="del_watching" name="del_watching" multiple="multiple" size="5">
+ [% FOREACH u = watching %]
+ <option value="[% u FILTER html %]">[% u FILTER html %]</option>
+ [% END %]
+ </select><br>
+ <input type="checkbox" id="remove_watched_users" name="remove_watched_users">
+ <label for="remove_watched_users">Remove selected users from my watch list</label>
+ [% ELSE %]
+ <i>You are currently not watching any users.</i>
+ [% END %]
+</p>
+
+<p>Add users to my watch list (comma separated list):
+ [% INCLUDE global/userselect.html.tmpl
+ id => "add_watching"
+ name => "add_watching"
+ value => ""
+ size => 60
+ multiple => 5
+ %]
+</p>
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl
new file mode 100644
index 000000000..3fc2ea66b
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl
@@ -0,0 +1,19 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+[% PROCESS "global/reason-descs.none.tmpl" %]
+From: [% Param('mailfrom') %]
+To: [% recipient.email %]
+Subject: [[% terms.Bugzilla %]] Your Outstanding Requests
+ ([% FOREACH type = requests.item(recipient.email).typelist %]
+ [%- requests.item(recipient.email).types.item(type).size %] [%+ type %]
+ [% ", " UNLESS loop.last %]
+ [% END %])
+Date: [% date %]
+X-Bugzilla-Type: nag
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl
new file mode 100644
index 000000000..b1b0eff7e
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl
@@ -0,0 +1,91 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+
+<!doctype html>
+<html>
+
+<head>
+ <title>[[% terms.Bugzilla %]] Your Outstanding Requests</title>
+</head>
+
+<body bgcolor="#ffffff">
+
+<p>
+ The following is a list of requests people have made of you, which are
+ currently outstanding. To avoid disappointing others, please deal with them as
+ quickly as possible.
+</p>
+
+[% requests = requests.item(recipient.login) %]
+[% FOREACH type = requests.typelist %]
+
+ <h3>
+ [% type FILTER upper FILTER html %] requests
+ <span style="font-size: x-small; font-weight: normal">
+ (<a href="[% urlbase FILTER none %]buglist.cgi?bug_id=
+ [% FOREACH request = requests.types.$type %]
+ [% request.bug.id FILTER none %]
+ [% "%2C" UNLESS loop.last %]
+ [% END %]">buglist</a>)
+ </span>
+ </h3>
+
+ <ul>
+ [% FOREACH request = requests.types.$type %]
+ <li>
+ <a href="[% urlbase FILTER none %]show_bug.cgi?id=[% request.bug.id FILTER none %]"
+ title="[% request.bug.bug_status FILTER html %]
+ [% request.bug.product FILTER html %] :: [% request.bug.component FILTER html %]">
+ [% request.bug.id FILTER none %] - [% request.bug.short_desc FILTER html %]
+ </a><br>
+ <b>[%+ request.flag.age FILTER html %]</b> from [% request.requester.identity FILTER html %]<br>
+ <div style="font-size: x-small">
+ [% IF request.attachment %]
+ <a href="[% urlbase FILTER none %]attachment.cgi?id=[% request.attachment.id FILTER none %]">Details</a>
+ [% IF request.attachment.ispatch %]
+ | <a href="[% urlbase FILTER none %]attachment.cgi?id=[% request.attachment.id FILTER none %]&amp;action=diff">Diff</a>
+ | <a href="[% urlbase FILTER none %]review?bug=[% request.bug.id FILTER none %]&amp;attachment=[% request.attachment.id FILTER none %]">Review</a>
+ [% END %]
+ |
+ [% END %]
+ <a href="[% urlbase FILTER none %]request_defer?flag=[% request.flag.id FILTER none %]">Defer</a>
+ </div>
+ <br>
+ </li>
+ [% END %]
+ </ul>
+
+[% END %]
+
+<div>
+ <hr style="border: 1px dashed #969696">
+ [% IF requests.types.item('review').size || requests.types.item('feedback').size %]
+ <a href="https://wiki.mozilla.org/BMO/Handling_Requests">
+ Guidance on handling requests
+ </a><br>
+ [% END %]
+ <a href="[% urlbase FILTER none %]request.cgi?action=queue&amp;requestee=[% recipient.login FILTER uri %]&amp;group=type">
+ See all your outstanding requests
+ </a><br>
+ <a href="[% urlbase FILTER none %]userprefs.cgi#request_nagging">
+ Opt out of these emails
+ </a><br>
+</div>
+
+<div style="font-size: 90%; color: #666666">
+ <hr style="border: 1px dashed #969696">
+ <b>You are receiving this mail because:</b>
+ <ul>
+ <li>You have outstanding requests.</li>
+ </ul>
+</div>
+
+</body>
+</html>
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl
new file mode 100644
index 000000000..83fbfcf71
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl
@@ -0,0 +1,45 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+
+The following is a list of requests people have made of you, which are
+currently outstanding. To avoid disappointing others, please deal with them as
+quickly as possible.
+
+[% requests = requests.item(recipient.login) %]
+[% FOREACH type = requests.typelist %]
+:: [% type FILTER upper FILTER html %] requests
+
+[% FOREACH request = requests.types.$type %]
+[[% terms.Bug %] [%+ request.bug.id %]] [% request.bug.short_desc %]
+ [%+ request.flag.age %] from [% request.requester.identity %]
+ [%+ urlbase %]show_bug.cgi?id=[% request.bug.id +%]
+ [% IF request.attachment && request.attachment.ispatch %]
+ Review: [% urlbase %]review?bug=[% request.bug.id %]&attachment=[% request.attachment.id %]
+ [% END %]
+ Defer: [% urlbase %]request_defer?flag=[% request.flag.id %]
+
+[% END %]
+[% END %]
+
+::
+
+[% IF requests.types.item('review').size || requests.types.item('feedback').size %]
+Guidance on handling requests:
+ https://wiki.mozilla.org/BMO/Handling_Requests
+[% END %]
+
+See all your outstanding requests:
+ [%+ urlbase %]request.cgi?action=queue&requestee=[% recipient.login FILTER uri %]&group=type
+
+Opt out of these emails:
+ [%+ urlbase %]userprefs.cgi#request_nagging
+
+--
+You are receiving this mail because: you have outstanding requests.
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl
new file mode 100644
index 000000000..5693f2be0
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl
@@ -0,0 +1,15 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+[% PROCESS "global/reason-descs.none.tmpl" %]
+From: [% Param('mailfrom') %]
+To: [% recipient.email %]
+Subject: [[% terms.Bugzilla %]] Outstanding Requests Report
+Date: [% date %]
+X-Bugzilla-Type: nag-watch
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl
new file mode 100644
index 000000000..e01167c5f
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl
@@ -0,0 +1,105 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+
+<!doctype html>
+<html>
+
+<head>
+ <title>[[% terms.Bugzilla %]] Outstanding Requests Report</title>
+</head>
+
+<body bgcolor="#ffffff">
+
+<p>
+ The following is a list of people who you are watching that have outstanding
+ requests.
+</p>
+
+<hr>
+
+[% FOREACH login = requests.keys.sort %]
+ [% requestee = requests.$login.requestee %]
+ [% requestee.identity FILTER html %]
+ <ul>
+ <li>
+ [%+ FOREACH type = requests.$login.typelist %]
+ [% requests.$login.types.item(type).size %] [%+ type FILTER html %]
+ [% ", " UNLESS loop.last %]
+ [% END %]
+ </li>
+ </ul>
+[% END %]
+
+[% FOREACH login = requests.keys.sort %]
+ [% requestee = requests.$login.requestee %]
+
+ [% bug_ids = [] %]
+ [% FOREACH type = requests.$login.typelist %]
+ [% FOREACH request = requests.$login.types.$type %]
+ [% bug_ids.push(request.bug.id) %]
+ [% END %]
+ [% END %]
+
+ <hr>
+ <h3>
+ [% requestee.identity FILTER html %]
+ <span style="font-size: x-small; font-weight: normal">
+ (<a href="[% urlbase FILTER none %]buglist.cgi?bug_id=[% bug_ids.join(",") FILTER uri %]">buglist</a>)
+ </span><br>
+ <span style="font-size: x-small; font-weight: normal">
+ [% FOREACH type = requests.$login.typelist %]
+ [% requests.$login.types.item(type).size %] [%+ type FILTER html %]
+ [% ", " UNLESS loop.last %]
+ [% END %]
+ </span>
+ </h3>
+
+ [% FOREACH type = requests.$login.typelist %]
+
+ <h3>[% type FILTER upper FILTER html %] requests</h3>
+
+ <ul>
+ [% FOREACH request = requests.$login.types.$type %]
+ <li>
+ <a href="[% urlbase FILTER none %]show_bug.cgi?id=[% request.bug.id FILTER none %]"
+ title="[% request.bug.bug_status FILTER html %]
+ [% request.bug.product FILTER html %] :: [% request.bug.component FILTER html %]">
+ [% request.bug.id FILTER none %] - [% request.bug.short_desc FILTER html %]
+ </a><br>
+ <b>[%+ request.flag.age FILTER html %]</b> from [% request.requester.identity FILTER html %]<br>
+ [% IF request.flag.deferred %]
+ Deferred until [%+ request.flag.deferred.ymd FILTER html %]<br>
+ [% END %]
+ <br>
+ </li>
+ [% END %]
+ </ul>
+
+ [% END %]
+
+[% END %]
+
+<div>
+ <hr style="border: 1px dashed #969696">
+ <a href="[% urlbase FILTER none %]userprefs.cgi?tab=request_nagging">
+ Change who you are watching
+ </a>
+</div>
+
+<div style="font-size: 90%; color: #666666">
+ <hr style="border: 1px dashed #969696">
+ <b>You are receiving this mail because:</b>
+ <ul>
+ <li>you are watching someone with outstanding requests.</li>
+ </ul>
+</div>
+
+</body>
+</html>
diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl
new file mode 100644
index 000000000..39ca8d004
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl
@@ -0,0 +1,47 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+
+The following is a list of people who you are watching that have outstanding
+requests.
+
+[% FOREACH login = requests.keys.sort %]
+[% requestee = requests.$login.requestee %]
+::
+:: [% requestee.identity %]
+:: [% FOREACH type = requests.$login.typelist %]
+ [%- requests.$login.types.item(type).size %] [%+ type %]
+ [% ", " UNLESS loop.last %]
+ [% END %]
+::
+
+[% FOREACH type = requests.$login.typelist %]
+:: [% type FILTER upper FILTER html %] requests
+
+[% FOREACH request = requests.$login.types.$type %]
+[[% terms.Bug %] [%+ request.bug.id %]] [% request.bug.short_desc %]
+ [%+ request.flag.age %] from [% request.requester.identity %]
+ [%+ urlbase %]show_bug.cgi?id=[% request.bug.id +%]
+ [% IF request.flag.deferred %]
+ Deferred until [%+ request.flag.deferred.ymd %]
+ [% END %]
+
+[% END %]
+[% END %]
+
+[% END %]
+
+::
+
+Change who you are watching
+ [%+ urlbase %]userprefs.cgi?tab=request_nagging
+
+--
+You are receiving this mail because: you are watching someone with outstanding
+requests.
diff --git a/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl
new file mode 100644
index 000000000..ed3e29c64
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl
@@ -0,0 +1,14 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% tabs = tabs.import([{
+ name => "request_nagging",
+ label => "Request Reminders",
+ link => "userprefs.cgi?tab=request_nagging",
+ saveable => 1
+ }]) %]
diff --git a/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl b/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl
new file mode 100644
index 000000000..795ca2ec0
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl
@@ -0,0 +1,16 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+<tr>
+ <th align="right">Remind for outstanding requests after:</th>
+ <td>
+ <input name="nag_interval" size="5"
+ value="[% product.id ? product.nag_interval / 24 : 7 %]">
+ days (Setting this to 0 disables request reminding).
+ </td>
+</tr>
diff --git a/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl b/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl
new file mode 100644
index 000000000..9baccce86
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl
@@ -0,0 +1,14 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% IF changes.nag_interval.defined %]
+ <p>
+ Changed request reminder interval from '[% changes.nag_interval.0 / 24 FILTER html %]' to
+ '[% product.nag_interval / 24 FILTER html %]'.
+ </p>
+[% END %]
diff --git a/extensions/RequestNagger/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/RequestNagger/template/en/default/hook/bug/show-header-end.html.tmpl
new file mode 100644
index 000000000..d91877dba
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/bug/show-header-end.html.tmpl
@@ -0,0 +1,9 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% style_urls.push("extensions/RequestNagger/web/requestnagger.css") %]
diff --git a/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl
new file mode 100644
index 000000000..aaec920a9
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl
@@ -0,0 +1,11 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[%
+ setting_descs.request_nagging = "Send me reminders for outstanding requests"
+%]
diff --git a/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl
new file mode 100644
index 000000000..12ef38370
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl
@@ -0,0 +1,25 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% IF error == "request_nagging_flag_invalid" %]
+ [% title = "Invalid Flag" %]
+ Invalid or missing Flag ID
+
+[% ELSIF error == "request_nagging_flag_set" %]
+ [% title = "Flag Already Set" %]
+ The requested Flag has been set, and is no longer pending.
+
+[% ELSIF error == "request_nagging_flag_wind" %]
+ [% title = "No Requestee" %]
+ The requested Flag does not have a requestee, and cannot be deferred.
+
+[% ELSIF error == "request_nagging_flag_not_owned" %]
+ [% title = "Not The Requestee" %]
+ You cannot defer Flags unless you are the requestee.
+
+[% END %]
diff --git a/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl b/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl
new file mode 100644
index 000000000..e89409ce1
--- /dev/null
+++ b/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl
@@ -0,0 +1,101 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # This Source Code Form is "Incompatible With Secondary Licenses", as
+ # defined by the Mozilla Public License, v. 2.0.
+ #%]
+
+[% PROCESS global/header.html.tmpl
+ title = "Defer Request Reminder"
+ style_urls = [ "extensions/RequestNagger/web/style/requestnagger.css" ]
+ javascript_urls = [ "js/util.js" , "extensions/RequestNagger/web/js/requestnagger.js" ]
+%]
+
+<h2>Defer Request Reminder</h2>
+
+[% IF saved %]
+ <div id="message">
+ Request reminder deferral has been saved.
+ </div>
+[% END %]
+
+<form method="post" action="page.cgi">
+<input type="hidden" name="id" value="request_defer.html">
+<input type="hidden" name="flag" value="[% flag.id FILTER none %]">
+<input type="hidden" name="save" value="1">
+
+<table class="edit_form">
+<tr><td>
+
+ <div class="flag-bug">
+ <a href="show_bug.cgi?id=[% flag.bug.id FILTER none %]">
+ [% terms.Bug %] [%+ flag.bug.id FILTER none %]
+ </a>
+ -
+ <a href="show_bug.cgi?id=[% flag.bug.id FILTER none %]">
+ [% flag.bug.short_desc FILTER html %]
+ </a>
+ </div>
+
+ [% IF flag.attachment %]
+ <div class="flag-attach">
+ <div class="flag-attach-desc">
+ <a href="attachment.cgi?id=[% flag.attachment.id FILTER none %]&amp;action=edit">
+ [% flag.attachment.description FILTER html %]
+ </a>
+ </div>
+ <div class="flag-attach-details">
+ [% flag.attachment.filename FILTER html %] ([% flag.attachment.contenttype FILTER html %]),
+ [% IF flag.attachment.datasize %]
+ [%+ flag.attachment.datasize FILTER unitconvert %]
+ [% ELSE %]
+ <em>deleted</em>
+ [% END %],
+ created by [%+ INCLUDE global/user.html.tmpl who = flag.attachment.attacher %]
+ </div>
+ [% IF flag.attachment.ispatch %]
+ <div class="flag-attach-actions">
+ <a href="attachment.cgi?id=[% flag.attachment.id FILTER none ~%]
+ &amp;action=diff">Diff</a> |
+ <a href="review?bug=[% flag.bug.id FILTER none ~%]
+ &amp;attachment=[% flag.attachment.id FILTER none %]">Review</a>
+ </div>
+ [% END %]
+ </div>
+ [% END %]
+
+ <div class="flag-details">
+ <span class="flag-type">
+ [% flag.type.name FILTER html %]
+ </span>
+ requested by [%+ INCLUDE global/user.html.tmpl who = flag.setter %]
+ [% flag.age FILTER html %]
+ </div>
+
+ [% IF saved %]
+ <div class="deferred">
+ Deferred until [% defer_until.ymd FILTER html %].
+ </div>
+ [% ELSE %]
+ <div class="defer">
+ Defer[% "ed" IF flag.deferred %] for
+ <select name="defer-until" id="defer-until">
+ [% FOREACH defer = defer_until %]
+ <option value="[% defer.date.ymd FILTER html %]"
+ [%+ "selected" IF flag.deferred.ymd == defer.date.ymd %]
+ >
+ [% defer.days FILTER html %] Day[% "s" UNLESS defer.days == 1 %]
+ </option>
+ [% END %]
+ </select>
+ <span id="defer-date"></span>
+ </div>
+ <input type="submit" value="Submit">
+ [% END %]
+</td></tr>
+</table>
+
+</form>
+
+[% PROCESS global/footer.html.tmpl %]
diff --git a/extensions/RequestNagger/web/js/requestnagger.js b/extensions/RequestNagger/web/js/requestnagger.js
new file mode 100644
index 000000000..e5cc43deb
--- /dev/null
+++ b/extensions/RequestNagger/web/js/requestnagger.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This Source Code Form is "Incompatible With Secondary Licenses", as
+ * defined by the Mozilla Public License, v. 2.0. */
+
+YAHOO.util.Event.onDOMReady(function() {
+ YAHOO.util.Event.addListener('defer-until', 'change', function() {
+ YAHOO.util.Dom.get('defer-date').innerHTML = 'until ' + this.value;
+ });
+ bz_fireEvent(YAHOO.util.Dom.get('defer-until'), 'change');
+});
diff --git a/extensions/RequestNagger/web/style/requestnagger.css b/extensions/RequestNagger/web/style/requestnagger.css
new file mode 100644
index 000000000..c4870a08e
--- /dev/null
+++ b/extensions/RequestNagger/web/style/requestnagger.css
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This Source Code Form is "Incompatible With Secondary Licenses", as
+ * defined by the Mozilla Public License, v. 2.0. */
+
+.edit_form {
+ width: 100%;
+}
+
+.flag-bug {
+ font-size: large;
+}
+
+.flag-bug, .flag-attach, .flag-details {
+ margin-bottom: 1em;
+}
+
+.flag-attach-details {
+ font-size: small;
+}
+
+.flag-attach-actions {
+ font-size: small;
+}
+
+.flag-attach-desc {
+ font-weight: bold;
+}
+
+.flag-type {
+ font-weight: bold;
+}
+
+.defer {
+ margin-bottom: 2em;
+}
+
+.deferred {
+ font-weight: bold;
+}
diff --git a/extensions/RequestWhiner/disabled b/extensions/RequestWhiner/disabled
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/extensions/RequestWhiner/disabled
diff --git a/skins/custom/global.css b/skins/custom/global.css
index 27232c274..cc155124a 100644
--- a/skins/custom/global.css
+++ b/skins/custom/global.css
@@ -71,3 +71,7 @@ a.controller {
width: 100%;
text-align: right;
}
+
+.highlighted {
+ background: lightyellow;
+}
diff --git a/template/en/default/account/prefs/settings.html.tmpl b/template/en/default/account/prefs/settings.html.tmpl
index f8b6ba487..65e31359b 100644
--- a/template/en/default/account/prefs/settings.html.tmpl
+++ b/template/en/default/account/prefs/settings.html.tmpl
@@ -42,7 +42,7 @@
[% FOREACH name = setting_names %]
[% default_name = name _ '-isdefault' %]
[% default_val = settings.${name}.default_value %]
- <tr>
+ <tr id="[% name FILTER html %]_row">
<td align="right">
[% setting_descs.$name OR name FILTER html %]
</td>
@@ -75,3 +75,10 @@
</table>
[% END %]
<br>
+
+<script>
+YAHOO.util.Event.onDOMReady(function() {
+ var id = document.location.hash.substring(1) + '_row';
+ YAHOO.util.Dom.addClass(id, 'highlighted');
+});
+</script>
diff --git a/userprefs.cgi b/userprefs.cgi
index e614d8111..b0969b93d 100755
--- a/userprefs.cgi
+++ b/userprefs.cgi
@@ -150,7 +150,7 @@ sub DoSettings {
my $settings = $user->settings;
$vars->{'settings'} = $settings;
- my @setting_list = keys %$settings;
+ my @setting_list = sort keys %$settings;
$vars->{'setting_names'} = \@setting_list;
$vars->{'has_settings_enabled'} = 0;