diff --git a/Bugzilla/Install.pm b/Bugzilla/Install.pm index 705a8396ca..993b4bccd7 100644 --- a/Bugzilla/Install.pm +++ b/Bugzilla/Install.pm @@ -257,6 +257,10 @@ use constant SYSTEM_GROUPS => ( name => 'bz_can_disable_mfa', description => 'Can disable MFA when editing users', }, + { + name => 'bz_can_async_bulk_edit', + description => 'Can asynchronously bulk edit bugs', + }, ); use constant DEFAULT_CLASSIFICATION => diff --git a/Bugzilla/Object/Lazy.pm b/Bugzilla/Object/Lazy.pm new file mode 100644 index 0000000000..50c8e2a5a5 --- /dev/null +++ b/Bugzilla/Object/Lazy.pm @@ -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. + +package Bugzilla::Object::Lazy; +use 5.10.1; +use Moo; + +has 'id' => (is => 'ro', required => 1); + +1; diff --git a/Bugzilla/Task.pm b/Bugzilla/Task.pm index 00e9d1fd43..c1675e6d98 100644 --- a/Bugzilla/Task.pm +++ b/Bugzilla/Task.pm @@ -54,4 +54,10 @@ sub _build_name { return decamelize($class); } +sub proper_name { + my ($self) = @_; + + return join(' ', map { ucfirst } split(/_/, $self->name)); +} + 1; diff --git a/Bugzilla/Task/BulkEdit.pm b/Bugzilla/Task/BulkEdit.pm new file mode 100644 index 0000000000..0cf95a1b6a --- /dev/null +++ b/Bugzilla/Task/BulkEdit.pm @@ -0,0 +1,104 @@ +# 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::Task::BulkEdit; +use 5.10.1; +use Moo; + +use Bugzilla::Error; +use DateTime::Duration; +use List::Util qw(any); +use Try::Tiny; +use Type::Utils qw(duck_type); +use Types::Standard -types; + +with 'Bugzilla::Task'; + +has 'ids' => (is => 'ro', isa => ArrayRef [Int], required => 1); +has 'set_all' => (is => 'ro', isa => HashRef, required => 1); +has 'ids_with_ts' => (is => 'lazy', isa => ArrayRef [Tuple [Int, Str]]); + +sub subject { + my ($self) = @_; + my @ids = @{$self->ids}; + + if (@ids > 100) { + return "Bulk Edit " . scalar(@ids) . " bugs"; + } + else { + return "Bulk Edit " . join(", ", @ids); + } +} + +sub _build_estimated_duration { + my ($self) = @_; + + return DateTime::Duration->new(seconds => 0 + @{$self->ids}); +} + +sub prepare { + my ($self) = @_; + + # pickup timestamps + $self->ids_with_ts; +} + +sub _build_ids_with_ts { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + return [] if @{$self->ids} == 0; + return $dbh->selectall_arrayref( + "SELECT bug_id, delta_ts FROM bugs WHERE @{[$dbh->sql_in('bug_id', $self->ids)]}" + ); +} + +sub run { + my ($self) = @_; + + return {async_bulk_edit => 1, all_sent_changes => [map { $self->edit_bug(@$_) } @{$self->ids_with_ts}]}; +} + +sub edit_bug { + my ($self, $bug_id, $delta_ts) = @_; + my $result; + try { + my $bug = Bugzilla::Bug->check($bug_id); + ThrowUserError('bulk_edit_stale', {bug => $bug, expected_delta_ts => $delta_ts}) + unless $bug->delta_ts eq $delta_ts; + ThrowUserError('product_edit_denied', {product => $bug->product}) + unless $self->user->can_edit_product($bug->product_obj->id); + + my $set_all_fields = $self->set_all; + + # Don't blindly ask to remove unchecked groups available in the UI. + # A group can be already unchecked, and the user didn't try to remove it. + # In this case, we don't want remove_group() to complain. + my @remove_groups; + my @unchecked_groups = @{$set_all_fields->{groups}{remove} // []}; + + foreach my $group (@{$bug->groups_in}) { + push(@remove_groups, $group->name) + if any { $_ eq $group->name } @unchecked_groups; + } + + local $set_all_fields->{groups}->{remove} = \@remove_groups; + $bug->set_all($set_all_fields); + my $changes = $bug->update(); + $result = $bug->send_changes($changes); + } + catch { + $result = { bug_id => $bug_id, error => $_ }; + } + finally { + Bugzilla::Bug->CLEANUP(); + }; + + return $result; +} + +1; diff --git a/process_bug.cgi b/process_bug.cgi index 7e22a6ef1a..ae04a12a51 100755 --- a/process_bug.cgi +++ b/process_bug.cgi @@ -26,6 +26,7 @@ use Bugzilla::Flag; use Bugzilla::Status; use Bugzilla::Token; use Bugzilla::Hook; +use Bugzilla::Task::BulkEdit; use Scalar::Util qw(blessed); use List::MoreUtils qw(firstidx); @@ -73,23 +74,36 @@ if (Bugzilla->params->{disable_bug_updates}) { # Create a list of objects for all bugs being modified in this request. my @bug_objects; +my @bug_ids; + +# $async_bulk_edit is whether or not we use the bulk editing code, +# $background is if the background=1 parameter was passed. +my $async_bulk_edit = $cgi->param('async_bulk_edit') // 0; +$cgi->delete('async_bulk_edit') if $async_bulk_edit; + if (defined $cgi->param('id')) { my $bug = Bugzilla::Bug->check(scalar $cgi->param('id')); $cgi->param('id', $bug->id); push(@bug_objects, $bug); } else { - foreach my $i ($cgi->param()) { - if ($i =~ /^id_([1-9][0-9]*)/) { - my $id = $1; - push(@bug_objects, Bugzilla::Bug->check($id)); - } + @bug_ids = map { /^id_([1-9][0-9]*)/ ? $1 : () } $cgi->param; + + # Make sure there are bugs to process. + ThrowUserError("no_bugs_chosen", {action => 'modify'}) unless @bug_ids; + $async_bulk_edit = 1 if @bug_ids > 10; + $async_bulk_edit = 0 unless $user->in_group('bz_can_async_bulk_edit'); + + if ($async_bulk_edit) { + # Not sure if we need $first_bug at all during a bulk update, + # but it won't hurt. + @bug_objects = (Bugzilla::Bug->check($bug_ids[0])); + } + else { + @bug_objects = map { Bugzilla::Bug->check($_) } @bug_ids; } } -# Make sure there are bugs to process. -scalar(@bug_objects) || ThrowUserError("no_bugs_chosen", {action => 'modify'}); - my $first_bug = $bug_objects[0]; # Used when we're only updating a single bug. # Delete any parameter set to 'dontchange'. @@ -226,9 +240,12 @@ else { # For each bug, we have to check if the user can edit the bug the product # is currently in, before we allow them to change anything. -foreach my $bug (@bug_objects) { - if (!Bugzilla->user->can_edit_product($bug->product_obj->id)) { - ThrowUserError("product_edit_denied", {product => $bug->product}); +# For bulk edits, this will be done in the background task. +if (!$async_bulk_edit) { + foreach my $bug (@bug_objects) { + if (!Bugzilla->user->can_edit_product($bug->product_obj->id)) { + ThrowUserError("product_edit_denied", {product => $bug->product}); + } } } @@ -255,7 +272,14 @@ my %field_translation = ( confirm_product_change => 'product_change_confirmed', ); -my %set_all_fields = (other_bugs => \@bug_objects); +my %set_all_fields; +if (not $async_bulk_edit) { + $set_all_fields{other_bugs} = \@bug_objects; +} +else { + require Bugzilla::Object::Lazy; + $set_all_fields{other_bugs} = [ map { Bugzilla::Object::Lazy->new(id => $_) } @bug_ids ]; +} foreach my $field_name (@set_fields) { if (should_set($field_name, 1)) { my $param_name = $field_translation{$field_name} || $field_name; @@ -278,6 +302,7 @@ if (should_set('comment')) { is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0, }; } + if (should_set('see_also')) { $set_all_fields{'see_also'}->{add} = [split(/[\s,]+/, $cgi->param('see_also'))]; } @@ -286,6 +311,7 @@ if (should_set('remove_see_also')) { } foreach my $dep_field (qw(dependson blocked)) { if (should_set($dep_field)) { + if (my $dep_action = $cgi->param("${dep_field}_action")) { $set_all_fields{$dep_field}->{$dep_action} = [split(/[\s,]+/, $cgi->param($dep_field))]; @@ -369,6 +395,23 @@ foreach my $field (keys %$user_match_fields) { # We are going to alter the list of removed groups, so we keep a copy here. my @unchecked_groups = @$removed_groups; + +if ($async_bulk_edit) { + my $task = Bugzilla::Task::BulkEdit->new( + ids => \@bug_ids, + set_all => \%set_all_fields, + user => Bugzilla->user, + ); + Bugzilla->job_queue->run_task($task); + my $name = $task->name; + # Delete the session token used for the mass-change. + delete_token($token) unless $cgi->param('id'); + print $cgi->header(); + $template->process("task/created.html.tmpl", {task => $task}) + or ThrowTemplateError($template->error); + exit; +} + foreach my $b (@bug_objects) { # Don't blindly ask to remove unchecked groups available in the UI. diff --git a/t/bulk-edit.t b/t/bulk-edit.t new file mode 100644 index 0000000000..a97fa7f98a --- /dev/null +++ b/t/bulk-edit.t @@ -0,0 +1,71 @@ +#!/usr/bin/env 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 5.10.1; +use lib qw( . lib local/lib/perl5 ); +use Storable qw(freeze); + +# this provides a default urlbase. +# Most localconfig options the other Bugzilla::Test::Mock* modules take care for us. +use Bugzilla::Test::MockLocalconfig (urlbase => 'http://bmo-web.vm'); + +use Bugzilla::Test::MockParams ( antispam_multi_user_limit_age => 0); + +# This configures an in-memory sqlite database. +use Bugzilla::Test::MockDB; + +# Util provides a few functions more making mock data in the DB. +use Bugzilla::Test::Util qw(create_user create_bug ); + +use Test2::V0; +use Test2::Tools::Mock; + +use ok 'Bugzilla::Task::BulkEdit'; + +my $user = create_user('bender@test.bot', '*'); +Bugzilla->set_user($user); + +my @bug_ids; +foreach (1..100) { + my $bug = create_bug( + short_desc => "a bug", + comment => "this is a bug", + assigned_to => scalar $user->login, + ); + push @bug_ids, $bug->id; +} + +is(0+@bug_ids, 100, "made 100 bugs"); + +my $task = Bugzilla::Task::BulkEdit->new( + user => $user, + ids => \@bug_ids, + set_all => { + comment => { + body => "bunnies", + is_private => 0, + }, + } +); +$task->prepare; + +try_ok { + local $Storable::Deparse = 0; + freeze($task); +} "Can we store the bulk edit task?"; + +my $results = $task->run; +if (my $edits = $results->{edits}) { + is(0 + @$edits, 100); +} + +my $comments = Bugzilla::Bug->check($bug_ids[-1])->comments; +is(0 + @$comments, 2); + +done_testing; diff --git a/template/en/default/bug/process/bugmail.html.tmpl b/template/en/default/bug/process/bugmail.html.tmpl index 824f640e52..58c8d9b141 100644 --- a/template/en/default/bug/process/bugmail.html.tmpl +++ b/template/en/default/bug/process/bugmail.html.tmpl @@ -35,6 +35,9 @@ %] [% recipient_count = sent_bugmail.sent.size %] +[% IF async_bulk_edit %] + [% show_recipients = 1 %] +[% ELSE %] +[% END %]
Task Error: [% error FILTER html %]
+[% END %] + + diff --git a/template/en/default/global/user-error.html.tmpl b/template/en/default/global/user-error.html.tmpl index 29b358b2b6..e89c8a622a 100644 --- a/template/en/default/global/user-error.html.tmpl +++ b/template/en/default/global/user-error.html.tmpl @@ -372,6 +372,10 @@ 'query.html#list' => "$terms.Bug lists"} %] You may not search, or create saved searches, without any search terms. + [% ELSIF error == "bulk_edit_stale" %] + [% title = "Bulk Edit is stale" %] + [% terms.Abug %] was updated before the bulk edit could be made. + [% ELSIF error == "cc_remove_denied" %] [% title = "Change Denied" %] You do not have permission to remove other people from the CC list. diff --git a/template/en/default/list/edit-multiple.html.tmpl b/template/en/default/list/edit-multiple.html.tmpl index 8224a3040f..227e0f7bce 100644 --- a/template/en/default/list/edit-multiple.html.tmpl +++ b/template/en/default/list/edit-multiple.html.tmpl @@ -370,6 +370,10 @@ [%+ Hook.process('after_groups') %] +[% IF user.in_group('bz_can_async_bulk_edit') %] + + +[% END %] [%############################################################################%] [%# Select Menu Block #%] diff --git a/template/en/default/task/created.html.tmpl b/template/en/default/task/created.html.tmpl new file mode 100644 index 0000000000..7bc89ef3c0 --- /dev/null +++ b/template/en/default/task/created.html.tmpl @@ -0,0 +1,14 @@ +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = task.subject, + header = task.subject, + style_urls = [ 'skins/standard/index.css' ] + no_yui = 1 +%] + +Scheduled [% task.proper_name FILTER html %] task. You'll recieve an email when it is done.
+