From e24f8ad92b6e814a9a623c7008716bbc5dad4435 Mon Sep 17 00:00:00 2001 From: Agustin Moyano Date: Thu, 25 Feb 2021 13:07:12 -0300 Subject: [PATCH] Bug 23678: Allow cancel holds in bulk This patch allows staff patrons to cancel multiple holds in bulk. To test: 1. Apply this patch 2. restart_all 3. In cataloge go to a book and place many holds CHECK => Holds table shows a column of checkboxes 4. Play with checkboxes (have some fun ;-P) CHECK => When you manually check all checkboxes, the checkbox in the header also gets checked. => When you uncheck one of the checkboxes, the one in the header also gets unchecked. => If no checkbox is checked and you check the one in the header, all checkboxes get checked. => If there are some checkboxes that are checked and others are not, when you click on the checkbox in the header all checkboxes get unchecked. => If all checkboxes are checked, when you uncheck the one in the header, all checkboxes get unchecked. => Every time you play with checkboxes, the number in the button "Cancel selected" changes. 5. Check some of the checkboxes and click on cancel selected. SUCCESS => A background job gets fired to cancel all selected holds. => A message should appear with a link to the job. 6. Wait a few seconds and click on the link SUCCESS => A message appears with the report of the execution of the background job. 7. Grab a patron and search to hold 8. Select multiple biblios and click on "place hold for " CHECK => After holds are confirmed, multiple holds table are shown.. one for each record. Checkboxes work exactly the same as before, but scoped for each individual table. Checkboxes from one table will not affect checkboxes from other tables. 9. Repeat steps 4 to 6. --- Koha/BackgroundJob.pm | 3 + Koha/BackgroundJob/BatchCancelHold.pm | 142 ++++++++++++++++++ .../prog/en/includes/holds_table.inc | 4 +- .../prog/en/modules/admin/background_jobs.tt | 32 ++++ .../prog/en/modules/reserve/request.tt | 74 ++++++++- misc/background_jobs_worker.pl | 2 +- reserve/request.pl | 14 ++ 7 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 Koha/BackgroundJob/BatchCancelHold.pm diff --git a/Koha/BackgroundJob.pm b/Koha/BackgroundJob.pm index 09139bdbdd..a939b1e150 100644 --- a/Koha/BackgroundJob.pm +++ b/Koha/BackgroundJob.pm @@ -26,6 +26,7 @@ use Koha::DateUtils qw( dt_from_string ); use Koha::Exceptions; use Koha::BackgroundJob::BatchUpdateBiblio; use Koha::BackgroundJob::BatchUpdateAuthority; +use Koha::BackgroundJob::BatchCancelHold; use base qw( Koha::Object ); @@ -155,6 +156,8 @@ sub process { ? Koha::BackgroundJob::BatchUpdateBiblio->process($args) : $job_type eq 'batch_authority_record_modification' ? Koha::BackgroundJob::BatchUpdateAuthority->process($args) + : $job_type eq 'batch_hold_cancel' + ? Koha::BackgroundJob::BatchCancelHold->process($args) : Koha::Exceptions::Exception->throw('->process called without valid job_type'); } diff --git a/Koha/BackgroundJob/BatchCancelHold.pm b/Koha/BackgroundJob/BatchCancelHold.pm new file mode 100644 index 0000000000..49149405ef --- /dev/null +++ b/Koha/BackgroundJob/BatchCancelHold.pm @@ -0,0 +1,142 @@ +package Koha::BackgroundJob::BatchCancelHold; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use JSON qw( encode_json decode_json ); + +use Koha::BackgroundJobs; +use Koha::DateUtils qw( dt_from_string ); +use Koha::Holds; + +use base 'Koha::BackgroundJob'; + +=head1 NAME + +Koha::BackgroundJob::BatchCancelHold - Batch cancel holds + +This is a subclass of Koha::BackgroundJob. + +=head1 API + +=head2 Class methods + +=head3 job_type + +Define the job type of this job: batch_hold_cancel + +=cut + +sub job_type { + return 'batch_hold_cancel'; +} + +=head3 process + +Process the modification. + +=cut + +sub process { + my ( $self, $args ) = @_; + + my $job = Koha::BackgroundJobs->find( $args->{job_id} ); + + if ( !exists $args->{job_id} || !$job || $job->status eq 'cancelled' ) { + return; + } + + my $job_progress = 0; + $job->started_on(dt_from_string) + ->progress($job_progress) + ->status('started') + ->store; + + my @hold_ids = @{ $args->{hold_ids} }; + + my $report = { + total_holds => scalar @hold_ids, + total_success => 0, + }; + my @messages; + HOLD_IDS: for my $hold_id ( sort { $a <=> $b } @hold_ids ) { + next unless $hold_id; + # Authorities + my $hold; + my $error = eval { + $hold = Koha::Holds->find($hold_id); + $hold->cancel({cancellation_reason => $args->{reason}}); + }; + my $patron = $hold->patron; + my $biblio = $hold->biblio; + if ( $error and $error != $hold or $@ ) { + push @messages, { + type => 'error', + code => 'hold_not_cancelled', + patron_id => $patron->borrowernumber, + patron_name => ($patron->firstname?$patron->firstname.', ':'').$patron->surname, + biblio_id => $biblio->biblionumber, + biblio_title => $biblio->title, + hold_id => $hold_id, + error => ($@ ? $@ : 0), + }; + } else { + push @messages, { + type => 'success', + code => 'hold_cancelled', + patron_id => $patron->borrowernumber, + patron_name => ($patron->firstname?$patron->firstname.', ':'').$patron->surname, + biblio_id => $biblio->biblionumber, + biblio_title => $biblio->title, + hold_id => $hold_id, + }; + $report->{total_success}++; + } + $job->progress( ++$job_progress )->store; + } + + my $job_data = decode_json $job->data; + $job_data->{messages} = \@messages; + $job_data->{report} = $report; + + $job->ended_on(dt_from_string) + ->data(encode_json $job_data); + $job->status('finished') if $job->status ne 'cancelled'; + $job->store; + +} + +=head3 enqueue + +Enqueue the new job + +=cut + +sub enqueue { + my ( $self, $args) = @_; + + # TODO Raise exception instead + return unless exists $args->{hold_ids}; + + my @hold_ids = @{ $args->{hold_ids} }; + + $self->SUPER::enqueue({ + job_size => scalar @hold_ids, + job_args => {hold_ids => \@hold_ids, reason => $args->{reason}} + }); +} + +1; diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc index 59f33cd2a0..fee360c65c 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/holds_table.inc @@ -1,8 +1,9 @@ [% USE Koha %] [% SET hold_cancellation = AuthorisedValues.GetAuthValueDropbox('HOLD_CANCELLATION') %] [% USE AuthorisedValues %] - +
+ [% IF ( CAN_user_reserveforothers_modify_holds_priority ) %] @@ -28,6 +29,7 @@ [% FOREACH hold IN holds %] [% IF !hold.found && first_priority == 0 %][% first_priority = hold.priority %][% END %] +
Priority  
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/background_jobs.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/background_jobs.tt index 86ffb3c540..66af5faeef 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/background_jobs.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/background_jobs.tt @@ -85,6 +85,20 @@ [% END %] [% END %] + [% CASE 'batch_hold_cancel' %] + [% SET report = job.report %] + [% IF report %] + [% IF report.total_holds == report.total_success %] +
+ All holds have successfully been cancelled! +
+ [% ELSE %] +
+ [% report.total_success | html %] / [% report.total_holds | html %] holds have successfully been modified. Some errors occurred. + [% IF job.status == 'cancelled' %]The job has been cancelled before it finished.[% END %] +
+ [% END %] + [% END %] [% CASE %]Job type "[% job.type | html %]" not handled in the template [% END %] @@ -126,6 +140,24 @@ [% END %] [% END %] + [% CASE 'batch_hold_cancel' %] + [% FOR m IN job.messages %] +
+ [% IF m.type == 'success' %] + + [% ELSIF m.type == 'warning' %] + + [% ELSIF m.type == 'error' %] + + [% END %] + [% SWITCH m.code %] + [% CASE 'hold_not_cancelled' %] + Hold on [% m.biblio_title | html %] for [% m.patron_name %] has not been cancelled. An error occurred on modifying it.[% IF m.error %] ([% m.error | html %])[% END %]. + [% CASE 'hold_cancelled' %] + Hold on [% m.biblio_title | html %] for [% m.patron_name %] has successfully been cancelled. + [% END %] +
+ [% END %] [% CASE %]Job type "[% job.type | html %]" not handled in the template [% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt index 5dfff18595..1fc5ab0d96 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt @@ -808,9 +808,16 @@ [% END %] + [% IF enqueued %] +
+

The job has been enqueued! It will be processed as soon as possible.

+

View detail of the enqueued job

+
+ [% END %] +

Existing holds

- +
[% SET hold_cancellation = AuthorisedValues.GetAuthValueDropbox('HOLD_CANCELLATION') %] [% IF hold_cancellation %] @@ -1015,6 +1022,8 @@ cannotBeTransferred: _("Cannot be transferred to pickup library"), pickupNotInHoldGroup: _("Only pickup locations within the same hold group are allowed") } + + var MSG_CANCEL_SELECTED = _("Cancel selected (%s)"); columns_settings_borrowers_table = [% TablesSettings.GetColumns( 'circ', 'circulation', 'table_borrowers', 'json' ) | $raw %]; $.fn.select2.defaults.set("width", "100%" ); $.fn.select2.defaults.set("dropdownAutoWidth", true ); @@ -1339,11 +1348,20 @@ return false; }); $("#cancelModalConfirmBtn").on("click",function(e) { - let borrowernumber = cancel_link.data('borrowernumber'); - let biblionumber = cancel_link.data('biblionumber'); - let reserve_id = cancel_link.data('id'); + let link; + if(cancel_link.data('bulk')) { + [% IF biblionumbers %] + link = `request.pl?biblionumbers=[% biblionumbers | url %]&action=cancelBulk&ids=${$('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')).join(',')}`; + [% ELSE %] + link = `request.pl?biblionumber=[% biblionumber | url %]&action=cancelBulk&ids=${$('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')).join(',')}`; + [% END %] + } else { + let borrowernumber = cancel_link.data('borrowernumber'); + let biblionumber = cancel_link.data('biblionumber'); + let reserve_id = cancel_link.data('id'); + link = `request.pl?action=cancel&borrowernumber=${ borrowernumber }&biblionumber=${ biblionumber }&reserve_id=${ reserve_id }`; + } let reason = $("#modal-cancellation-reason").val(); - let link = `request.pl?action=cancel&borrowernumber=${ borrowernumber }&biblionumber=${ biblionumber }&reserve_id=${ reserve_id }`; if ( reason ) { link += "&cancellation-reason=" + reason } @@ -1391,6 +1409,52 @@ stickTo: "#existing_holds", stickyClass: "floating" }); + + if(!localStorage.selectedHolds) { + localStorage.selectedHolds = []; + } + + $('.holds_table .select_hold').each(function() { + if(localStorage.selectedHolds.includes($(this).data('id'))) { + $(this).prop('checked', true); + } + }); + + $('.holds_table .select_hold_all').each(function() { + var table = $(this).parents('.holds_table'); + var count = $('.select_hold:not(:checked)', table).length; + $('.select_hold_all', table).prop('checked', !count); + }); + + $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + + $('.holds_table .select_hold_all').click(function() { + var table = $(this).parents('.holds_table'); + var count = $('.select_hold:checked', table).length; + $('.select_hold', table).prop('checked', !count); + $(this).prop('checked', !count); + $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + localStorage.selectedHolds = $('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')); + }); + + $('.holds_table .select_hold').click(function() { + var table = $(this).parents('.holds_table'); + var count = $('.select_hold:not(:checked)', table).length; + $('.select_hold_all', table).prop('checked', !count); + $('.cancel_selected_holds').html(MSG_CANCEL_SELECTED.format($('.holds_table .select_hold:checked').length)); + localStorage.selectedHolds = $('.holds_table .select_hold:checked').toArray().map(el => $(el).data('id')); + }); + + $('.cancel_selected_holds').click(function(e) { + e.preventDefault(); + if($('.holds_table .select_hold:checked').length) { + cancel_link = $(this); + delete localStorage.selectedHolds; + $('#cancelModal').modal(); + } + return false; + }); + }); [% END %] diff --git a/misc/background_jobs_worker.pl b/misc/background_jobs_worker.pl index 0520189fa5..05de0ae2b3 100755 --- a/misc/background_jobs_worker.pl +++ b/misc/background_jobs_worker.pl @@ -28,7 +28,7 @@ try { warn sprintf "Cannot connect to the message broker, the jobs will be processed anyway (%s)", $_; }; -my @job_types = qw( batch_biblio_record_modification batch_authority_record_modification ); +my @job_types = qw( batch_biblio_record_modification batch_authority_record_modification batch_hold_cancel ); if ( $conn ) { # FIXME cf note in Koha::BackgroundJob about $namespace diff --git a/reserve/request.pl b/reserve/request.pl index 1751a0aa92..52a5967618 100755 --- a/reserve/request.pl +++ b/reserve/request.pl @@ -54,6 +54,7 @@ use Koha::ItemTypes; use Koha::Libraries; use Koha::Patrons; use Koha::Clubs; +use Koha::BackgroundJob::BatchCancelHold; my $dbh = C4::Context->dbh; my $input = CGI->new; @@ -115,6 +116,19 @@ if ( $action eq 'move' ) { my $reserve_id = $input->param('reserve_id'); my $suspend_until = $input->param('suspend_until'); ToggleSuspend( $reserve_id, $suspend_until ); +} elsif ( $action eq 'cancelBulk') { + my $cancellation_reason = $input->param("cancellation-reason"); + my @hold_ids = split ',', $input->param("ids"); + my $params = { + reason => $cancellation_reason, + hold_ids => \@hold_ids, + }; + my $job_id = Koha::BackgroundJob::BatchCancelHold->new->enqueue($params); + + $template->param( + enqueued => 1, + job_id => $job_id + ) } if ($findborrower) { -- 2.25.0