From b99ca00a1094a7bcaa23de7ace1d8c9686f208a2 Mon Sep 17 00:00:00 2001 From: Pedro Amorim Date: Fri, 12 May 2023 17:06:01 +0000 Subject: [PATCH] Bug 30719: ILL Batches - Add batch column to requests table - Establish if there are any availability or metadata enrichment plugins and pass that to the template - Verify if we have any backend that can support batches, if not, don't show the option - Updates to the ILL toolbar - New ILL batch modal - New Koha classes - API specs Co-authored-by: Andrew Isherwood --- Koha/Illbatch.pm | 196 +++ Koha/Illbatches.pm | 61 + Koha/REST/V1/Illbatches.pm | 256 ++++ admin/columns_settings.yml | 2 + api/v1/swagger/definitions/illbatch.yaml | 48 + api/v1/swagger/definitions/illbatches.yaml | 5 + api/v1/swagger/paths/illbatches.yaml | 236 ++++ ill/ill-requests.pl | 105 +- .../prog/css/src/staff-global.scss | 106 ++ .../en/includes/ill-batch-modal-strings.inc | 38 + .../prog/en/includes/ill-batch-modal.inc | 97 ++ .../en/includes/ill-batch-table-strings.inc | 9 + .../prog/en/includes/ill-batch.inc | 19 + .../en/includes/ill-list-table-strings.inc | 1 + .../prog/en/includes/ill-list-table.inc | 1 + .../prog/en/includes/ill-toolbar.inc | 21 +- .../prog/en/modules/ill/ill-requests.tt | 52 +- .../intranet-tmpl/prog/js/ill-batch-modal.js | 1093 +++++++++++++++++ .../intranet-tmpl/prog/js/ill-batch-table.js | 216 ++++ koha-tmpl/intranet-tmpl/prog/js/ill-batch.js | 49 + .../intranet-tmpl/prog/js/ill-list-table.js | 15 +- 21 files changed, 2617 insertions(+), 9 deletions(-) create mode 100644 Koha/Illbatch.pm create mode 100644 Koha/Illbatches.pm create mode 100644 Koha/REST/V1/Illbatches.pm create mode 100644 api/v1/swagger/definitions/illbatch.yaml create mode 100644 api/v1/swagger/definitions/illbatches.yaml create mode 100644 api/v1/swagger/paths/illbatches.yaml create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch.js diff --git a/Koha/Illbatch.pm b/Koha/Illbatch.pm new file mode 100644 index 0000000000..f0e427eec9 --- /dev/null +++ b/Koha/Illbatch.pm @@ -0,0 +1,196 @@ +package Koha::Illbatch; + +# Copyright PTFS Europe 2022 +# +# 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 Koha::Database; +use Koha::Illrequest::Logger; +use Koha::IllbatchStatus; +use JSON qw( to_json ); +use base qw(Koha::Object); + +=head1 NAME + +Koha::Illbatch - Koha Illbatch Object class + +=head2 Class methods + +=head3 status + + my $status = Koha::Illbatch->status; + +Return the status object associated with this batch + +=cut + +sub status { + my ( $self ) = @_; + return Koha::IllbatchStatus->_new_from_dbic( + scalar $self->_result->statuscode + ); +} + +=head3 patron + + my $patron = Koha::Illbatch->patron; + +Return the patron object associated with this batch + +=cut + +sub patron { + my ( $self ) = @_; + return Koha::Patron->_new_from_dbic( + scalar $self->_result->borrowernumber + ); +} + +=head3 branch + + my $branch = Koha::Illbatch->branch; + +Return the branch object associated with this batch + +=cut + +sub branch { + my ( $self ) = @_; + return Koha::Library->_new_from_dbic( + scalar $self->_result->branchcode + ); +} + +=head3 requests_count + + my $requests_count = Koha::Illbatch->requests_count; + +Return the number of requests associated with this batch + +=cut + +sub requests_count { + my ( $self ) = @_; + return Koha::Illrequests->search({ + batch_id => $self->id + })->count; +} + +=head3 create_and_log + + $batch->create_and_log; + +Log batch creation following storage + +=cut + +sub create_and_log { + my ( $self ) = @_; + + $self->store; + + my $logger = Koha::Illrequest::Logger->new; + + $logger->log_something({ + modulename => 'ILL', + actionname => 'batch_create', + objectnumber => $self->id, + infos => to_json({}) + }); +} + +=head3 update_and_log + + $batch->update_and_log; + +Log batch update following storage + +=cut + +sub update_and_log { + my ( $self, $params ) = @_; + + my $before = { + name => $self->name, + branchcode => $self->branchcode + }; + + $self->set( $params ); + my $update = $self->store; + + my $after = { + name => $self->name, + branchcode => $self->branchcode + }; + + my $logger = Koha::Illrequest::Logger->new; + + $logger->log_something({ + modulename => 'ILL', + actionname => 'batch_update', + objectnumber => $self->id, + infos => to_json({ + before => $before, + after => $after + }) + }); +} + +=head3 delete_and_log + + $batch->delete_and_log; + +Log batch delete + +=cut + +sub delete_and_log { + my ( $self ) = @_; + + my $logger = Koha::Illrequest::Logger->new; + + $logger->log_something({ + modulename => 'ILL', + actionname => 'batch_delete', + objectnumber => $self->id, + infos => to_json({}) + }); + + $self->delete; +} + +=head2 Internal methods + +=head3 _type + + my $type = Koha::Illbatch->_type; + +Return this object's type + +=cut + +sub _type { + return 'Illbatch'; +} + +=head1 AUTHOR + +Andrew Isherwood + +=cut + +1; diff --git a/Koha/Illbatches.pm b/Koha/Illbatches.pm new file mode 100644 index 0000000000..ee65a262d6 --- /dev/null +++ b/Koha/Illbatches.pm @@ -0,0 +1,61 @@ +package Koha::Illbatches; + +# Copyright PTFS Europe 2022 +# +# 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 Koha::Database; +use Koha::Illbatch; +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Illbatches - Koha Illbatches Object class + +=head2 Internal methods + +=head3 _type + + my $type = Koha::Illbatches->_type; + +Return this object's type + +=cut + +sub _type { + return 'Illbatch'; +} + +=head3 object_class + + my $class = Koha::Illbatches->object_class; + +Return this object's class name + +=cut + +sub object_class { + return 'Koha::Illbatch'; +} + +=head1 AUTHOR + +Andrew Isherwood + +=cut + +1; diff --git a/Koha/REST/V1/Illbatches.pm b/Koha/REST/V1/Illbatches.pm new file mode 100644 index 0000000000..fbadb6ecd6 --- /dev/null +++ b/Koha/REST/V1/Illbatches.pm @@ -0,0 +1,256 @@ +package Koha::REST::V1::Illbatches; + +# 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 Mojo::Base 'Mojolicious::Controller'; + +use Koha::Illbatches; +use Koha::IllbatchStatuses; +use Koha::Illrequests; + +use Try::Tiny qw( catch try ); + +=head1 NAME + +Koha::REST::V1::Illbatches + +=head2 Operations + +=head3 list + +Return a list of available ILL batches + +=cut + +sub list { + my $c = shift->openapi->valid_input; + + #FIXME: This should be $c->objects-search + my @batches = Koha::Illbatches->search()->as_list; + + #FIXME: Below should be coming from $c->objects accessors + # Get all patrons associated with all our batches + # in one go + my $patrons = {}; + foreach my $batch(@batches) { + my $patron_id = $batch->borrowernumber; + $patrons->{$patron_id} = 1 + }; + my @patron_ids = keys %{$patrons}; + my $patron_results = Koha::Patrons->search({ + borrowernumber => { -in => \@patron_ids } + }); + + # Get all branches associated with all our batches + # in one go + my $branches = {}; + foreach my $batch(@batches) { + my $branch_id = $batch->branchcode; + $branches->{$branch_id} = 1 + }; + my @branchcodes = keys %{$branches}; + my $branch_results = Koha::Libraries->search({ + branchcode => { -in => \@branchcodes } + }); + + # Get all batch statuses associated with all our batches + # in one go + my $statuses = {}; + foreach my $batch(@batches) { + my $code = $batch->statuscode; + $statuses->{$code} = 1 + }; + my @statuscodes = keys %{$statuses}; + my $status_results = Koha::IllbatchStatuses->search({ + code => { -in => \@statuscodes } + }); + + # Populate the response + my @to_return = (); + foreach my $it_batch(@batches) { + my $patron = $patron_results->find({ borrowernumber => $it_batch->borrowernumber}); + my $branch = $branch_results->find({ branchcode => $it_batch->branchcode }); + my $status = $status_results->find({ code => $it_batch->statuscode }); + push @to_return, { + %{$it_batch->unblessed}, + patron => $patron, + branch => $branch, + status => $status, + requests_count => $it_batch->requests_count + }; + } + + return $c->render( status => 200, openapi => \@to_return ); +} + +=head3 get + +Get one batch + +=cut + +sub get { + my $c = shift->openapi->valid_input; + + my $batchid = $c->validation->param('illbatch_id'); + + my $batch = Koha::Illbatches->find($batchid); + + if (not defined $batch) { + return $c->render( + status => 404, + openapi => { error => "ILL batch not found" } + ); + } + + return $c->render( + status => 200, + openapi => { + %{$batch->unblessed}, + patron => $batch->patron->unblessed, + branch => $batch->branch->unblessed, + status => $batch->status->unblessed, + requests_count => $batch->requests_count + } + ); +} + +=head3 add + +Add a new batch + +=cut + +sub add { + my $c = shift->openapi->valid_input or return; + + my $body = $c->validation->param('body'); + + # We receive cardnumber, so we need to look up the corresponding + # borrowernumber + my $patron = Koha::Patrons->find({ cardnumber => $body->{cardnumber} }); + + if ( not defined $patron ) { + return $c->render( + status => 404, + openapi => { error => "Patron with cardnumber " . $body->{cardnumber} . " not found" } + ); + } + + delete $body->{cardnumber}; + $body->{borrowernumber} = $patron->borrowernumber; + + return try { + my $batch = Koha::Illbatch->new( $body ); + $batch->create_and_log; + $c->res->headers->location( $c->req->url->to_string . '/' . $batch->id ); + + my $ret = { + %{$batch->unblessed}, + patron => $batch->patron->unblessed, + branch => $batch->branch->unblessed, + status => $batch->status->unblessed, + requests_count => 0 + }; + + return $c->render( + status => 201, + openapi => $ret + ); + } + catch { + if ( blessed $_ ) { + if ( $_->isa('Koha::Exceptions::Object::DuplicateID') ) { + return $c->render( + status => 409, + openapi => { error => "A batch named " . $body->{name} . " already exists" } + ); + } + } + $c->unhandled_exception($_); + }; +} + +=head3 update + +Update a batch + +=cut + +sub update { + my $c = shift->openapi->valid_input or return; + + my $batch = Koha::Illbatches->find( $c->validation->param('illbatch_id') ); + + if ( not defined $batch ) { + return $c->render( + status => 404, + openapi => { error => "ILL batch not found" } + ); + } + + my $params = $c->req->json; + delete $params->{cardnumber}; + + return try { + $batch->update_and_log( $params ); + + my $ret = { + %{$batch->unblessed}, + patron => $batch->patron->unblessed, + branch => $batch->branch->unblessed, + status => $batch->status->unblessed, + requests_count => $batch->requests_count + }; + + return $c->render( + status => 200, + openapi => $ret + ); + } + catch { + $c->unhandled_exception($_); + }; +} + +=head3 delete + +Delete a batch + +=cut + +sub delete { + + my $c = shift->openapi->valid_input or return; + + my $batch = Koha::Illbatches->find( $c->validation->param( 'illbatch_id' ) ); + + if ( not defined $batch ) { + return $c->render( status => 404, openapi => { error => "ILL batch not found" } ); + } + + return try { + $batch->delete_and_log; + return $c->render( status => 204, openapi => ''); + } + catch { + $c->unhandled_exception($_); + }; +} + +1; diff --git a/admin/columns_settings.yml b/admin/columns_settings.yml index bbdf3d4213..fe8e551c89 100644 --- a/admin/columns_settings.yml +++ b/admin/columns_settings.yml @@ -825,6 +825,8 @@ modules: columns: - columnname: ill_request_id + - + columnname: batch - columnname: metadata_author - diff --git a/api/v1/swagger/definitions/illbatch.yaml b/api/v1/swagger/definitions/illbatch.yaml new file mode 100644 index 0000000000..d3146a3303 --- /dev/null +++ b/api/v1/swagger/definitions/illbatch.yaml @@ -0,0 +1,48 @@ +--- +type: object +properties: + id: + type: string + description: Internal ILL batch identifier + name: + type: string + description: Name of the ILL batch + backend: + type: string + description: Backend name + cardnumber: + type: string + description: Card number of the patron of the ILL batch + borrowernumber: + type: string + description: Borrower number of the patron of the ILL batch + branchcode: + type: string + description: Branch code of the branch of the ILL batch + patron: + type: + - object + - "null" + description: The patron associated with the batch + branch: + type: + - object + - "null" + description: The branch associated with the batch + statuscode: + type: string + description: Code of the status of the ILL batch + status: + type: + - object + - "null" + description: The status associated with the batch + requests_count: + type: string + description: The number of requests in this batch +additionalProperties: false +required: + - name + - backend + - branchcode + - statuscode \ No newline at end of file diff --git a/api/v1/swagger/definitions/illbatches.yaml b/api/v1/swagger/definitions/illbatches.yaml new file mode 100644 index 0000000000..4db20f480d --- /dev/null +++ b/api/v1/swagger/definitions/illbatches.yaml @@ -0,0 +1,5 @@ +--- +type: array +items: + $ref: "illbatch.yaml" +additionalProperties: false diff --git a/api/v1/swagger/paths/illbatches.yaml b/api/v1/swagger/paths/illbatches.yaml new file mode 100644 index 0000000000..30a67355d8 --- /dev/null +++ b/api/v1/swagger/paths/illbatches.yaml @@ -0,0 +1,236 @@ +--- +/illbatches: + get: + x-mojo-to: Illbatches#list + operationId: listIllbatches + tags: + - illbatches + summary: List ILL batches + parameters: [] + produces: + - application/json + responses: + "200": + description: A list of ILL batches + schema: + $ref: "../swagger.yaml#/definitions/illbatches" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: ILL batches not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" + post: + x-mojo-to: Illbatches#add + operationId: addIllbatch + tags: + - illbatches + summary: Add ILL batch + parameters: + - name: body + in: body + description: A JSON object containing informations about the new batch + required: true + schema: + $ref: "../swagger.yaml#/definitions/illbatch" + produces: + - application/json + responses: + "201": + description: Batch added + schema: + $ref: "../swagger.yaml#/definitions/illbatch" + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Patron with given cardnumber not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "409": + description: Conflict in creating resource + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" +"/illbatches/{illbatch_id}": + get: + x-mojo-to: Illbatches#get + operationId: getIllbatches + tags: + - illbatches + summary: Get ILL batch + parameters: + - name: illbatch_id + in: path + description: ILL batch id/name/contents + required: true + type: string + produces: + - application/json + responses: + "200": + description: An ILL batch + schema: + $ref: "../swagger.yaml#/definitions/illbatch" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: ILL batch not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" + put: + x-mojo-to: Illbatches#update + operationId: updateIllBatch + tags: + - illbatches + summary: Update batch + parameters: + - $ref: "../swagger.yaml#/parameters/illbatch_id_pp" + - name: body + in: body + description: A JSON object containing information on the batch + required: true + schema: + $ref: "../swagger.yaml#/definitions/illbatch" + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: An ILL batch + schema: + $ref: "../swagger.yaml#/definitions/illbatch" + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: ILL batch not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" + delete: + x-mojo-to: Illbatches#delete + operationId: deleteBatch + tags: + - illbatches + summary: Delete ILL batch + parameters: + - $ref: "../swagger.yaml#/parameters/illbatch_id_pp" + produces: + - application/json + responses: + "204": + description: ILL batch deleted + schema: + type: string + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: ILL batch not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" diff --git a/ill/ill-requests.pl b/ill/ill-requests.pl index a549d9cd5b..c10bfa5caf 100755 --- a/ill/ill-requests.pl +++ b/ill/ill-requests.pl @@ -27,10 +27,12 @@ use Koha::Notice::Templates; use Koha::AuthorisedValues; use Koha::Illcomment; use Koha::Illrequests; +use Koha::Illbatches; use Koha::Illrequest::Workflow::Availability; use Koha::Illrequest::Workflow::TypeDisclaimer; use Koha::Libraries; use Koha::Token; +use Koha::Plugins; use Try::Tiny qw( catch try ); use URI::Escape qw( uri_escape_utf8 ); @@ -66,10 +68,27 @@ my $has_branch = $cfg->has_branch; my $backends_available = ( scalar @{$backends} > 0 ); $template->param( backends_available => $backends_available, - has_branch => $has_branch + has_branch => $has_branch, + have_batch => have_batch_backends($backends) ); if ( $backends_available ) { + # Establish what metadata enrichment plugins we have available + my $enrichment_services = get_metadata_enrichment(); + if (scalar @{$enrichment_services} > 0) { + $template->param( + metadata_enrichment_services => encode_json($enrichment_services) + ); + } + # Establish whether we have any availability services that can provide availability + # for the batch identifier types we support + my $batch_availability_services = get_ill_availability($enrichment_services); + if (scalar @{$batch_availability_services} > 0) { + $template->param( + batch_availability_services => encode_json($batch_availability_services) + ); + } + if ( $op eq 'illview' ) { # View the details of an ILL my $request = Koha::Illrequests->find($params->{illrequest_id}); @@ -136,8 +155,8 @@ if ( $backends_available ) { } $template->param( - whole => $backend_result, - request => $request + whole => $backend_result, + request => $request ); handle_commit_maybe($backend_result, $request); } @@ -203,6 +222,9 @@ if ( $backends_available ) { # We simulate the API for backend requests for uniformity. # So, init: my $request = Koha::Illrequests->find($params->{illrequest_id}); + my $batches = Koha::Illbatches->search(undef, { + order_by => { -asc => 'name' } + }); if ( !$params->{stage} ) { my $backend_result = { error => 0, @@ -215,13 +237,15 @@ if ( $backends_available ) { }; $template->param( whole => $backend_result, - request => $request + request => $request, + batches => $batches ); } else { # Commit: # Save the changes $request->borrowernumber($params->{borrowernumber}); $request->biblio_id($params->{biblio_id}); + $request->batch_id($params->{batch_id}); $request->branchcode($params->{branchcode}); $request->price_paid($params->{price_paid}); $request->notesopac($params->{notesopac}); @@ -353,7 +377,7 @@ if ( $backends_available ) { } elsif ( $op eq 'illlist') { # If we receive a pre-filter, make it available to the template - my $possible_filters = ['borrowernumber']; + my $possible_filters = ['borrowernumber', 'batch_id']; my $active_filters = {}; foreach my $filter(@{$possible_filters}) { if ($params->{$filter}) { @@ -372,6 +396,17 @@ if ( $backends_available ) { $template->param( prefilters => join("&", @tpl_arr) ); + + if ($active_filters->{batch_id}) { + my $batch_id = $active_filters->{batch_id}; + if ($batch_id) { + my $batch = Koha::Illbatches->find($batch_id); + $template->param( + batch => $batch + ); + } + } + } elsif ( $op eq "save_comment" ) { die "Wrong CSRF token" unless Koha::Token->new->check_csrf({ session_id => scalar $cgi->cookie('CGISESSID'), @@ -406,6 +441,10 @@ if ( $backends_available ) { scalar $params->{illrequest_id} . $append ); exit; + } elsif ( $op eq "batch_list" ) { + # Do not remove, it prevents us falling through to the 'else' + } elsif ( $op eq "batch_create" ) { + # Do not remove, it prevents us falling through to the 'else' } else { my $request = Koha::Illrequests->find($params->{illrequest_id}); my $backend_result = $request->custom_capability($op, $params); @@ -463,3 +502,59 @@ sub redirect_to_list { print $cgi->redirect('/cgi-bin/koha/ill/ill-requests.pl'); exit; } + +# Do any of the available backends provide batch requesting +sub have_batch_backends { + my ( $backends ) = @_; + + my @have_batch = (); + + foreach my $backend(@{$backends}) { + my $can_batch = can_batch($backend); + if ($can_batch) { + push @have_batch, $backend; + } + } + return \@have_batch; +} + +# Does a given backend provide batch requests +# FIXME: This should be moved to Koha::Illbackend +sub can_batch { + my ( $backend ) = @_; + my $request = Koha::Illrequest->new->load_backend( $backend ); + return $request->_backend_capability( 'provides_batch_requests' ); +} + +# Get available metadata enrichment plugins +sub get_metadata_enrichment { + my @candidates = Koha::Plugins->new()->GetPlugins({ + method => 'provides_api' + }); + my @services = (); + foreach my $plugin(@candidates) { + my $supported = $plugin->provides_api(); + if ($supported->{type} eq 'search') { + push @services, $supported; + } + } + return \@services; +} + +# Get ILL availability plugins that can help us with the batch identifier types +# we support +sub get_ill_availability { + my ( $services ) = @_; + + my $id_types = {}; + foreach my $service(@{$services}) { + foreach my $id_supported(keys %{$service->{identifiers_supported}}) { + $id_types->{$id_supported} = 1; + } + } + + my $availability = Koha::Illrequest::Workflow::Availability->new($id_types); + return $availability->get_services({ + ui_context => 'staff' + }); +} diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss index 34719c10b8..151e93bb5a 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -3747,6 +3747,112 @@ input.renew { } #interlibraryloans { + + .ill-toolbar { + display: flex; + } + + #ill-batch { + flex-grow: 1; + display: flex; + justify-content: flex-end; + gap: 5px; + } + + #ill-batch-requests { + .action-buttons { + display: flex; + gap: 5px; + justify-content: center; + } + } + + #ill-batch-modal { + .modal-footer { + display: flex; + & > * { + flex: 1; + } + #lhs { + text-align: left; + } + } + #create-progress { + margin-top: 17px; + } + .fetch-failed { + background-color: rgba(255,0,0,0.1); + & > * { + background-color: inherit; + } + } + .progress { + margin-bottom: 0; + margin-top: 17px; + } + #create-requests { + display: flex; + justify-content: flex-end; + } + .action-column { + text-align: center; + & > * { + margin-left: 5px; + } + & > *:first-child { + margin-left: 0; + } + } + .metadata-row:not(:first-child) { + margin-top: 0.5em; + } + .metadata-label { + font-weight: 600; + } + .more-less { + text-align: right; + margin: 2px 0; + } + + } + + #batch-form { + legend { + margin-bottom: 2em; + } + textarea { + width: 100%; + min-height: 100px; + padding: 5px; + resize: vertical; + } + #new-batch-form { + display: flex; + gap: 20px; + } + li#process-button { + display: flex; + justify-content: flex-end; + } + #textarea-metadata { + padding: 0 15px; + display: flex; + justify-content: space-between; + } + #textarea-errors { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px 15px 10px + } + .batch-modal-actions { + text-align: center; + } + fieldset { + border: 2px solid #b9d8d9; + } + } + #dataPreviewLabel { margin: .3em 0; } diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc new file mode 100644 index 0000000000..69737a6259 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc @@ -0,0 +1,38 @@ + + + diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc new file mode 100644 index 0000000000..72903bb48d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc @@ -0,0 +1,97 @@ + + diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc new file mode 100644 index 0000000000..2a8d4051e5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc @@ -0,0 +1,9 @@ + + + diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc new file mode 100644 index 0000000000..8c0ca21e74 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc @@ -0,0 +1,19 @@ +[% IF query_type == "batch_list" %] +
+ + + + + + + + + + + + + + +
Batch IDNameNumber of requestsStatusPatronBranch
+
+[% END %] \ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table-strings.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table-strings.inc index f4327f40eb..bf1ef077f9 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table-strings.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table-strings.inc @@ -1,5 +1,6 @@ + + [% END %] [% INCLUDE 'ill-list-table-strings.inc' %] + [% INCLUDE 'ill-batch-table-strings.inc' %] + [% INCLUDE 'ill-batch-modal-strings.inc' %] [% Asset.js("js/ill-list-table.js") | $raw %] + [% Asset.js("js/ill-batch.js") | $raw %] + [% Asset.js("js/ill-batch-table.js") | $raw %] + [% Asset.js("js/ill-batch-modal.js") | $raw %] [% IF (query_type == 'availability' || query_type == 'generic_confirm') && Koha.Preference('ILLCheckAvailability') %] [% Asset.js("js/ill-availability.js") | $raw %] [% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js b/koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js new file mode 100644 index 0000000000..bf434ecd56 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js @@ -0,0 +1,1093 @@ +(function () { + // Bail if there aren't any metadata enrichment plugins installed + if (typeof metadata_enrichment_services === 'undefined') { + console.log('No metadata enrichment plugins found.') + return; + } + + window.addEventListener('load', onload); + + // Delay between API requests + var debounceDelay = 1000; + + // Elements we work frequently with + var textarea = document.getElementById("identifiers_input"); + var nameInput = document.getElementById("name"); + var cardnumberInput = document.getElementById("batchcardnumber"); + var branchcodeSelect = document.getElementById("branchcode"); + var processButton = document.getElementById("process_button"); + var createButton = document.getElementById("button_create_batch"); + var finishButton = document.getElementById("button_finish"); + var batchItemsDisplay = document.getElementById("add_batch_items"); + var createProgressTotal = document.getElementById("processed_total"); + var createProgressCount = document.getElementById("processed_count"); + var createProgressFailed = document.getElementById("processed_failed"); + var createProgressBar = document.getElementById("processed_progress_bar"); + var identifierTable = document.getElementById('identifier-table'); + var createRequestsButton = document.getElementById('create-requests-button'); + var statusesSelect = document.getElementById('statuscode'); + var cancelButton = document.getElementById('lhs').querySelector('button'); + var cancelButtonOriginalText = cancelButton.innerHTML; + + // We need a data structure keyed on identifier type, which tells us how to parse that + // identifier type and what services can get its metadata. We receive an array of + // available services + var supportedIdentifiers = {}; + metadata_enrichment_services.forEach(function (service) { + // Iterate the identifiers that this service supports + Object.keys(service.identifiers_supported).forEach(function (idType) { + if (!supportedIdentifiers[idType]) { + supportedIdentifiers[idType] = []; + } + supportedIdentifiers[idType].push(service); + }); + }); + + // An object for when we're creating a new batch + var emptyBatch = { + name: '', + backend: null, + cardnumber: '', + branchcode: '', + statuscode: 'NEW' + }; + + // The object that holds the batch we're working with + // It's a proxy so we can update portions of the UI + // upon changes + var batch = new Proxy( + { data: {} }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + manageBatchItemsDisplay(); + updateBatchInputs(); + disableCardnumberInput(); + displayPatronName(); + updateStatusesSelect(); + } + } + ); + + // The object that holds the contents of the table + // It's a proxy so we can make it automatically redraw the + // table upon changes + var tableContent = new Proxy( + { data: [] }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + updateTable(); + updateRowCount(); + updateProcessTotals(); + checkAvailability(); + } + } + ); + + // The object that holds the contents of the table + // It's a proxy so we can update portions of the UI + // upon changes + var statuses = new Proxy( + { data: [] }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + updateStatusesSelect(); + } + } + ); + + var progressTotals = new Proxy( + { + data: {} + }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + showCreateRequestsButton(); + } + } + ); + + // Keep track of submission API calls that are in progress + // so we don't duplicate them + var submissionSent = {}; + + // Keep track of availability API calls that are in progress + // so we don't duplicate them + var availabilitySent = {}; + + // Are we updating an existing batch + var isUpdate = false; + + // The datatable + var table; + var tableEl = document.getElementById('identifier-table'); + + // The element that potentially holds the ID of the batch + // we're working with + var idEl = document.getElementById('ill-batch-details'); + var batchId = null; + var backend = null; + + function onload() { + $('#ill-batch-modal').on('shown.bs.modal', function () { + init(); + patronAutocomplete(); + batchInputsEventListeners(); + createButtonEventListener(); + createRequestsButtonEventListener(); + moreLessEventListener(); + removeRowEventListener(); + }); + $('#ill-batch-modal').on('hidden.bs.modal', function () { + // Reset our state when we close the modal + // TODO: need to also reset progress bar and already processed identifiers + delete idEl.dataset.batchId; + delete idEl.dataset.backend; + batchId = null; + tableEl.style.display = 'none'; + tableContent.data = []; + progressTotals.data = { + total: 0, + count: 0, + failed: 0 + }; + textarea.value = ''; + batch.data = {}; + cancelButton.innerHTML = cancelButtonOriginalText; + // Remove event listeners we created + removeEventListeners(); + }); + }; + + function init() { + batchId = idEl.dataset.batchId; + backend = idEl.dataset.backend; + emptyBatch.backend = backend; + progressTotals.data = { + total: 0, + count: 0, + failed: 0 + }; + if (batchId) { + fetchBatch(); + isUpdate = true; + setModalHeading(); + } else { + batch.data = emptyBatch; + setModalHeading(); + } + fetchStatuses(); + finishButtonEventListener(); + processButtonEventListener(); + identifierTextareaEventListener(); + displaySupportedIdentifiers(); + createButtonEventListener(); + updateRowCount(); + }; + + function initPostCreate() { + disableCreateButton(); + cancelButton.innerHTML = ill_batch_create_cancel_button; + }; + + function setFinishButton() { + if (batch.data.patron) { + finishButton.removeAttribute('disabled'); + } + }; + + function setModalHeading() { + var heading = document.getElementById('ill-batch-modal-label'); + heading.textContent = isUpdate ? ill_batch_update : ill_batch_add; + } + + // Identify items that have metadata and therefore can have a local request + // created, and do so + function requestRequestable() { + createRequestsButton.setAttribute('disabled', true); + createRequestsButton.setAttribute('aria-disabled', true); + setFinishButton(); + var toCheck = tableContent.data; + toCheck.forEach(function (row) { + if ( + !row.requestId && + Object.keys(row.metadata).length > 0 && + !submissionSent[row.value] + ) { + submissionSent[row.value] = 1; + makeLocalSubmission(row.value, row.metadata); + } + }); + }; + + // Identify items that can have their availability checked, and do it + function checkAvailability() { + // Only proceed if we've got services that can check availability + if (!batch_availability_services || batch_availability_services.length === 0) return; + var toCheck = tableContent.data; + toCheck.forEach(function (row) { + if ( + !row.url && + Object.keys(row.metadata).length > 0 && + !availabilitySent[row.value] + ) { + availabilitySent[row.value] = 1; + getAvailability(row.value, row.metadata); + } + }); + }; + + // Check availability services for immediate availability, if found, + // create a link in the table linking to the item + function getAvailability(identifier, metadata) { + // Prep the metadata for passing to the availability plugins + let availability_object = {}; + if (metadata.issn) availability_object['issn'] = metadata.issn; + if (metadata.doi) availability_object['doi'] = metadata.doi; + if (metadata.pubmedid) availability_object['pubmedid'] = metadata.pubmedid; + var prepped = encodeURIComponent(base64EncodeUnicode(JSON.stringify(availability_object))); + for (i = 0; i < batch_availability_services.length; i++) { + var service = batch_availability_services[i]; + window.doApiRequest( + service.endpoint + prepped + ) + .then(function (response) { + return response.json(); + }) + .then(function (data) { + if (data.results.search_results && data.results.search_results.length > 0) { + var result = data.results.search_results[0]; + tableContent.data = tableContent.data.map(function (row) { + if (row.value === identifier) { + row.url = result.url; + row.availabilitySupplier = service.name; + } + return row; + }); + } + }); + } + }; + + // Help btoa with > 8 bit strings + // Shamelessly grabbed from: https://www.base64encoder.io/javascript/ + function base64EncodeUnicode(str) { + // First we escape the string using encodeURIComponent to get the UTF-8 encoding of the characters, + // then we convert the percent encodings into raw bytes, and finally feed it to btoa() function. + utf8Bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) { + return String.fromCharCode('0x' + p1); + }); + + return btoa(utf8Bytes); + }; + + // Create a local submission and update our local state + // upon success + function makeLocalSubmission(identifier, metadata) { + + // Prepare extended_attributes in array format for POST + var extended_attributes = []; + for (const [key, value] of Object.entries(metadata)) { + extended_attributes.push({"type":key, "value":value}); + } + + let date = new Date(); + var payload = { + batch_id: batchId, + ill_backend_id: batch.data.backend, + patron_id: batch.data.patron.borrowernumber, + library_id: batch.data.branchcode, + //FIXME: requested_date and timestamp should not be required by the API, they are handled by the backend create_api method + requested_date: $date_to_rfc3339(date.toString()), + timestamp: date, + extended_attributes: extended_attributes + }; + window.doCreateSubmission(payload) + .then(function (response) { + return response.json(); + }) + .then(function (data) { + tableContent.data = tableContent.data.map(function (row) { + if (row.value === identifier) { + row.requestId = data.ill_request_id; + row.requestStatus = data.status; + } + return row; + }); + }) + .catch(function () { + window.handleApiError(ill_batch_api_request_fail); + }); + }; + + function updateProcessTotals() { + var init = { + total: 0, + count: 0, + failed: 0 + }; + progressTotals.data = init; + var toUpdate = progressTotals.data; + tableContent.data.forEach(function (row) { + toUpdate.total++; + if (Object.keys(row.metadata).length > 0 || row.failed.length > 0) { + toUpdate.count++; + } + if (Object.keys(row.failed).length > 0) { + toUpdate.failed++; + } + }); + createProgressTotal.innerHTML = toUpdate.total; + createProgressCount.innerHTML = toUpdate.count; + createProgressFailed.innerHTML = toUpdate.failed; + var percentDone = Math.ceil((toUpdate.count / toUpdate.total) * 100); + createProgressBar.setAttribute('aria-valuenow', percentDone); + createProgressBar.innerHTML = percentDone + '%'; + createProgressBar.style.width = percentDone + '%'; + progressTotals.data = toUpdate; + }; + + function displayPatronName() { + var span = document.getElementById('patron_link'); + if (batch.data.patron) { + var link = createPatronLink(); + span.appendChild(link); + } else { + if (span.children.length > 0) { + span.removeChild(span.firstChild); + } + } + }; + + function updateStatusesSelect() { + while (statusesSelect.options.length > 0) { + statusesSelect.remove(0); + } + statuses.data.forEach(function (status) { + var option = document.createElement('option') + option.value = status.code; + option.text = status.name; + if (batch.data.id && batch.data.statuscode === status.code) { + option.selected = true; + } + statusesSelect.add(option); + }); + if (isUpdate) { + statusesSelect.parentElement.style.display = 'block'; + } + }; + + function removeEventListeners() { + textarea.removeEventListener('paste', processButtonState); + textarea.removeEventListener('keyup', processButtonState); + processButton.removeEventListener('click', processIdentifiers); + nameInput.removeEventListener('keyup', createButtonState); + cardnumberInput.removeEventListener('keyup', createButtonState); + branchcodeSelect.removeEventListener('change', createButtonState); + createButton.removeEventListener('click', createBatch); + identifierTable.removeEventListener('click', toggleMetadata); + identifierTable.removeEventListener('click', removeRow); + createRequestsButton.remove('click', requestRequestable); + }; + + function finishButtonEventListener() { + finishButton.addEventListener('click', doFinish); + }; + + function identifierTextareaEventListener() { + textarea.addEventListener('paste', textareaUpdate); + textarea.addEventListener('keyup', textareaUpdate); + }; + + function processButtonEventListener() { + processButton.addEventListener('click', processIdentifiers); + }; + + function createRequestsButtonEventListener() { + createRequestsButton.addEventListener('click', requestRequestable); + }; + + function createButtonEventListener() { + createButton.addEventListener('click', createBatch); + }; + + function batchInputsEventListeners() { + nameInput.addEventListener('keyup', createButtonState); + cardnumberInput.addEventListener('keyup', createButtonState); + branchcodeSelect.addEventListener('change', createButtonState); + }; + + function moreLessEventListener() { + identifierTable.addEventListener('click', toggleMetadata); + }; + + function removeRowEventListener() { + identifierTable.addEventListener('click', removeRow); + }; + + function textareaUpdate() { + processButtonState(); + updateRowCount(); + }; + + function processButtonState() { + if (textarea.value.length > 0) { + processButton.removeAttribute('disabled'); + processButton.removeAttribute('aria-disabled'); + } else { + processButton.setAttribute('disabled', true); + processButton.setAttribute('aria-disabled', true); + } + }; + + function disableCardnumberInput() { + if (batch.data.patron) { + cardnumberInput.setAttribute('disabled', true); + cardnumberInput.setAttribute('aria-disabled', true); + } else { + cardnumberInput.removeAttribute('disabled'); + cardnumberInput.removeAttribute('aria-disabled'); + } + }; + + function createButtonState() { + if ( + nameInput.value.length > 0 && + cardnumberInput.value.length > 0 && + branchcodeSelect.selectedOptions.length === 1 + ) { + createButton.removeAttribute('disabled'); + createButton.setAttribute('display', 'inline-block'); + } else { + createButton.setAttribute('disabled', 1); + createButton.setAttribute('display', 'none'); + } + }; + + function doFinish() { + updateBatch() + .then(function () { + $('#ill-batch-modal').modal({ show: false }); + location.href = '/cgi-bin/koha/ill/ill-requests.pl?batch_id=' + batch.data.id; + }); + }; + + // Get all batch statuses + function fetchStatuses() { + window.doApiRequest('/api/v1/illbatchstatuses') + .then(function (response) { + return response.json(); + }) + .then(function (jsoned) { + statuses.data = jsoned; + }) + .catch(function (e) { + window.handleApiError(ill_batch_statuses_api_fail); + }); + }; + + // Get the batch + function fetchBatch() { + window.doBatchApiRequest("/" + batchId) + .then(function (response) { + return response.json(); + }) + .then(function (jsoned) { + batch.data = { + id: jsoned.id, + name: jsoned.name, + backend: jsoned.backend, + cardnumber: jsoned.cardnumber, + branchcode: jsoned.branchcode, + statuscode: jsoned.statuscode + } + return jsoned; + }) + .then(function (data) { + batch.data = data; + }) + .catch(function () { + window.handleApiError(ill_batch_api_fail); + }); + }; + + function createBatch() { + var selectedBranchcode = branchcodeSelect.selectedOptions[0].value; + var selectedStatuscode = statusesSelect.selectedOptions[0].value; + return doBatchApiRequest('', { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + name: nameInput.value, + backend: backend, + cardnumber: cardnumberInput.value, + branchcode: selectedBranchcode, + statuscode: selectedStatuscode + }) + }) + .then(function (response) { + if ( response.ok ) { + return response.json(); + } + return Promise.reject(response); + }) + .then(function (body) { + batchId = body.id; + batch.data = { + id: body.id, + name: body.name, + backend: body.backend, + cardnumber: body.patron.cardnumber, + branchcode: body.branchcode, + statuscode: body.statuscode, + patron: body.patron, + status: body.status + }; + initPostCreate(); + }) + .catch(function (response) { + response.json().then((json) => { + if( json.error ) { + handleApiError(json.error); + } else { + handleApiError(ill_batch_create_api_fail); + } + }) + }); + }; + + function updateBatch() { + var selectedBranchcode = branchcodeSelect.selectedOptions[0].value; + var selectedStatuscode = statusesSelect.selectedOptions[0].value; + return doBatchApiRequest('/' + batch.data.id, { + method: 'PUT', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + name: nameInput.value, + backend: batch.data.backend, + cardnumber: batch.data.patron.cardnumber, + branchcode: selectedBranchcode, + statuscode: selectedStatuscode + }) + }) + .catch(function () { + handleApiError(ill_batch_update_api_fail); + }); + }; + + function displaySupportedIdentifiers() { + var names = Object.keys(supportedIdentifiers).map(function (identifier) { + return window['ill_batch_' + identifier]; + }); + var displayEl = document.getElementById('supported_identifiers'); + displayEl.textContent = names.length > 0 ? names.join(', ') : ill_batch_none; + } + + function updateRowCount() { + var textEl = document.getElementById('row_count_value'); + var val = textarea.value.trim(); + var cnt = 0; + if (val.length > 0) { + cnt = val.split(/\n/).length; + } + textEl.textContent = cnt; + } + + function showProgress() { + var el = document.getElementById('create-progress'); + el.style.display = 'block'; + } + + function showCreateRequestsButton() { + var data = progressTotals.data; + var el = document.getElementById('create-requests'); + el.style.display = (data.total > 0 && data.count === data.total) ? 'flex' : 'none'; + } + + async function processIdentifiers() { + var content = textarea.value; + hideErrors(); + if (content.length === 0) return; + + disableProcessButton(); + var label = document.getElementById('progress-label').firstChild; + label.innerHTML = ill_batch_retrieving_metadata; + showProgress(); + + // Errors encountered when processing + var processErrors = {}; + + // Prepare the content, including trimming each row + var contentArr = content.split(/\n/); + var trimmed = contentArr.map(function (row) { + return row.trim(); + }); + + var parsed = []; + + trimmed.forEach(function (identifier) { + var match = identifyIdentifier(identifier); + // If this identifier is not identifiable or + // looks like more than one type, we can't be sure + // what it is + if (match.length != 1) { + parsed.push({ + type: 'unknown', + value: identifier + }); + } else { + parsed.push(match[0]); + } + }); + + var unknownIdentifiers = parsed + .filter(function (parse) { + if (parse.type == 'unknown') { + return parse; + } + }) + .map(function (filtered) { + return filtered.value; + }); + + if (unknownIdentifiers.length > 0) { + processErrors.badidentifiers = { + element: 'badids', + values: unknownIdentifiers.join(', ') + }; + }; + + // Deduping + var deduped = []; + var dupes = {}; + parsed.forEach(function (row) { + var value = row.value; + var alreadyInDeduped = deduped.filter(function (d) { + return d.value === value; + }); + if (alreadyInDeduped.length > 0 && !dupes[value]) { + dupes[value] = 1; + } else if (alreadyInDeduped.length === 0) { + row.metadata = {}; + row.failed = {}; + row.requestId = null; + deduped.push(row); + } + }); + // Update duplicate error if dupes were found + if (Object.keys(dupes).length > 0) { + processErrors.duplicates = { + element: 'dupelist', + values: Object.keys(dupes).join(', ') + }; + } + + // Display any errors + displayErrors(processErrors); + + // Now build and display the table + if (!table) { + buildTable(); + } + + // We may be appending new values to an existing table, + // in which case, ensure we don't create duplicates + var tabIdentifiers = tableContent.data.map(function (tabId) { + return tabId.value; + }); + var notInTable = deduped.filter(function (ded) { + if (!tabIdentifiers.includes(ded.value)) { + return ded; + } + }); + if (notInTable.length > 0) { + tableContent.data = tableContent.data.concat(notInTable); + } + + // Populate metadata for those records that need it + var newData = tableContent.data; + for (var i = 0; i < tableContent.data.length; i++) { + var row = tableContent.data[i]; + // Skip rows that don't need populating + if ( + Object.keys(tableContent.data[i].metadata).length > 0 || + Object.keys(tableContent.data[i].failed).length > 0 + ) continue; + var identifier = { type: row.type, value: row.value }; + try { + var populated = await populateMetadata(identifier); + row.metadata = populated.results.result || {}; + } catch (e) { + row.failed = ill_populate_failed; + } + newData[i] = row; + tableContent.data = newData; + } + } + + function disableProcessButton() { + processButton.setAttribute('disabled', true); + processButton.setAttribute('aria-disabled', true); + } + + function disableCreateButton() { + createButton.setAttribute('disabled', true); + createButton.setAttribute('aria-disabled', true); + } + + async function populateMetadata(identifier) { + // All services that support this identifier type + var services = supportedIdentifiers[identifier.type]; + // Check each service and use the first results we get, if any + for (var i = 0; i < services.length; i++) { + var service = services[i]; + var endpoint = '/api/v1/contrib/' + service.api_namespace + service.search_endpoint + '?' + identifier.type + '=' + identifier.value; + var metadata = await getMetadata(endpoint); + if (metadata.errors.length === 0) { + var parsed = await parseMetadata(metadata, service); + if (parsed.errors.length > 0) { + throw Error(metadata.errors.map(function (error) { + return error.message; + }).join(', ')); + } + return parsed; + } + } + }; + + async function getMetadata(endpoint) { + var response = await debounce(doApiRequest)(endpoint); + return response.json(); + }; + + async function parseMetadata(metadata, service) { + var endpoint = '/api/v1/contrib/' + service.api_namespace + service.ill_parse_endpoint; + var response = await doApiRequest(endpoint, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(metadata) + }); + return response.json(); + } + + // A render function for identifier type + function createIdentifierType(data) { + return window['ill_batch_' + data]; + }; + + // Get an item's title + function getTitle(meta) { + if (meta.article_title && meta.article_title.length > 0) { + return 'article_title'; + return { + prop: 'article_title', + value: meta.article_title + }; + } else if (meta.title && meta.title.length > 0) { + return 'title'; + return { + prop: 'title', + value: meta.title + }; + } + }; + + // Create a metadata row + function createMetadataRow(data, meta, prop) { + if (!meta[prop]) return; + + var div = document.createElement('div'); + div.classList.add('metadata-row'); + var label = document.createElement('span'); + label.classList.add('metadata-label'); + label.innerText = ill_batch_metadata[prop] + ': '; + + // Add a link to the availability URL if appropriate + var value; + if (!data.url) { + value = document.createElement('span'); + } else { + value = document.createElement('a'); + value.setAttribute('href', data.url); + value.setAttribute('target', '_blank'); + value.setAttribute('title', ill_batch_available_via + ' ' + data.availabilitySupplier); + } + value.classList.add('metadata-value'); + value.innerText = meta[prop]; + div.appendChild(label); + div.appendChild(value); + + return div; + } + + // A render function for displaying metadata + function createMetadata(x, y, data) { + // If the fetch failed + if (data.failed.length > 0) { + return data.failed; + } + + // If we've not yet got any metadata back + if (Object.keys(data.metadata).length === 0) { + return ill_populate_waiting; + } + + var core = ['doi', 'pmid', 'issn', 'title', 'year', 'issue', 'pages', 'publisher', 'article_title', 'article_author', 'volume']; + var meta = data.metadata; + + var container = document.createElement('div'); + container.classList.add('metadata-container'); + + // Create the title row + var title = getTitle(meta); + if (title) { + // Remove the title element from the props + // we're about to iterate + core = core.filter(function (i) { + return i !== title; + }); + var titleRow = createMetadataRow(data, meta, title); + container.appendChild(titleRow); + } + + var remainder = document.createElement('div'); + remainder.classList.add('metadata-remainder'); + remainder.style.display = 'none'; + // Create the remaining rows + core.sort().forEach(function (prop) { + var div = createMetadataRow(data, meta, prop); + if (div) { + remainder.appendChild(div); + } + }); + container.appendChild(remainder); + + // Add a more/less toggle + var firstField = container.firstChild; + var moreLess = document.createElement('div'); + moreLess.classList.add('more-less'); + var moreLessLink = document.createElement('a'); + moreLessLink.setAttribute('href', '#'); + moreLessLink.classList.add('more-less-link'); + moreLessLink.innerText = ' [' + ill_batch_metadata_more + ']'; + moreLess.appendChild(moreLessLink); + firstField.appendChild(moreLess); + + return container.outerHTML; + }; + + function removeRow(ev) { + if (ev.target.className.includes('remove-row')) { + if (!confirm(ill_batch_item_remove)) return; + // Find the parent row + var ancestor = ev.target.closest('tr'); + var identifier = ancestor.querySelector('.identifier').innerText; + tableContent.data = tableContent.data.filter(function (row) { + return row.value !== identifier; + }); + } + } + + function toggleMetadata(ev) { + if (ev.target.className === 'more-less-link') { + // Find the element we need to show + var ancestor = ev.target.closest('.metadata-container'); + var meta = ancestor.querySelector('.metadata-remainder'); + + // Display or hide based on its current state + var display = window.getComputedStyle(meta).display; + + meta.style.display = display === 'block' ? 'none' : 'block'; + + // Update the More / Less text + ev.target.innerText = ' [ ' + (display === 'none' ? ill_batch_metadata_less : ill_batch_metadata_more) + ' ]'; + } + } + + // A render function for the link to a request ID + function createRequestId(x, y, data) { + return data.requestId || '-'; + } + + function createRequestStatus(x, y, data) { + return data.requestStatus || '-'; + } + + function buildTable(identifiers) { + table = KohaTable('identifier-table', { + processing: true, + deferRender: true, + ordering: false, + paging: false, + searching: false, + autoWidth: false, + columns: [ + { + data: 'type', + width: '13%', + render: createIdentifierType + }, + { + data: 'value', + width: '25%', + className: 'identifier' + }, + { + data: 'metadata', + render: createMetadata + }, + { + data: 'requestId', + width: '6.5%', + render: createRequestId + }, + { + data: 'requestStatus', + width: '6.5%', + render: createRequestStatus + }, + { + width: '18%', + render: createActions, + className: 'action-column' + } + ], + createdRow: function (row, data) { + if (data.failed.length > 0) { + row.classList.add('fetch-failed'); + } + } + }); + } + + function createActions(x, y, data) { + return ''; + } + + // Redraw the table + function updateTable() { + if (!table) return; + tableEl.style.display = tableContent.data.length > 0 ? 'table' : 'none'; + tableEl.style.width = '100%'; + table.api() + .clear() + .rows.add(tableContent.data) + .draw(); + }; + + function identifyIdentifier(identifier) { + var matches = []; + + // Iterate our available services to see if any can identify this identifier + Object.keys(supportedIdentifiers).forEach(function (identifierType) { + // Since all the services supporting this identifier type should use the same + // regex to identify it, we can just use the first + var service = supportedIdentifiers[identifierType][0]; + var regex = new RegExp(service.identifiers_supported[identifierType].regex); + var match = identifier.match(regex); + if (match && match.groups && match.groups.identifier) { + matches.push({ + type: identifierType, + value: match.groups.identifier + }); + } + }); + return matches; + } + + function displayErrors(errors) { + var keys = Object.keys(errors); + if (keys.length > 0) { + keys.forEach(function (key) { + var el = document.getElementById(errors[key].element); + el.textContent = errors[key].values; + el.style.display = 'inline'; + var container = document.getElementById(key); + container.style.display = 'block'; + }); + var el = document.getElementById('textarea-errors'); + el.style.display = 'flex'; + } + } + + function hideErrors() { + var dupelist = document.getElementById('dupelist'); + var badids = document.getElementById('badids'); + dupelist.textContent = ''; + dupelist.parentElement.style.display = 'none'; + badids.textContent = ''; + badids.parentElement.style.display = 'none'; + var tae = document.getElementById('textarea-errors'); + tae.style.display = 'none'; + } + + function manageBatchItemsDisplay() { + batchItemsDisplay.style.display = batch.data.id ? 'block' : 'none' + }; + + function updateBatchInputs() { + nameInput.value = batch.data.name || ''; + cardnumberInput.value = batch.data.cardnumber || ''; + branchcodeSelect.value = batch.data.branchcode || ''; + } + + function debounce(func) { + var timeout; + return function (...args) { + return new Promise(function (resolve) { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(function () { + return resolve(func(...args)); + }, debounceDelay); + }); + } + } + + function patronAutocomplete() { + patron_autocomplete( + $('#batch-form #batchcardnumber'), + { + 'on-select-callback': function( event, ui ) { + $("#batch-form #batchcardnumber").val( ui.item.cardnumber ); + return false; + } + } + ); + }; + + function createPatronLink() { + if (!batch.data.patron) return; + var patron = batch.data.patron; + var a = document.createElement('a'); + var href = '/cgi-bin/koha/members/moremember.pl?borrowernumber=' + patron.borrowernumber; + var text = patron.surname + ' (' + patron.cardnumber + ')'; + a.setAttribute('title', ill_borrower_details); + a.setAttribute('href', href); + a.textContent = text; + return a; + }; + +})(); diff --git a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js b/koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js new file mode 100644 index 0000000000..605efba436 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js @@ -0,0 +1,216 @@ +(function () { + var table; + var batchesProxy; + + window.addEventListener('load', onload); + + function onload() { + // Only proceed if appropriate + if (!document.getElementById('ill-batch-requests')) return; + + // A proxy that will give us some element of reactivity to + // changes in our list of batches + batchesProxy = new Proxy( + { data: [] }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + updateTable(); + } + } + ); + + // Initialise the Datatable, binding it to our proxy object + table = initTable(); + + // Do the initial data population + window.doBatchApiRequest() + .then(function (response) { + return response.json(); + }) + .then(function (data) { + batchesProxy.data = data; + }); + + // Clean up any event listeners we added + window.addEventListener('beforeunload', removeEventListeners); + }; + + // Initialise the Datatable + // FIXME: This should be a kohaTable not KohaTable + var initTable = function () { + return KohaTable("ill-batch-requests", { + data: batchesProxy.data, + columns: [ + { + data: 'id', + width: '10%' + }, + { + data: 'name', + render: createName, + width: '30%' + }, + { + data: 'requests_count', + width: '10%' + }, + { + data: 'status', + render: createStatus, + width: '10%' + }, + { + data: 'patron', + render: createPatronLink, + width: '10%' + }, + { + data: 'branch', + render: createBranch, + width: '20%' + }, + { + render: createActions, + width: '10%', + orderable: false + } + ], + processing: true, + deferRender: true, + drawCallback: addEventListeners + }); + } + + // A render function for branch name + var createBranch = function (data) { + return data.branchname; + }; + + // A render function for batch name + var createName = function (x, y, data) { + var a = document.createElement('a'); + a.setAttribute('href', '/cgi-bin/koha/ill/ill-requests.pl?batch_id=' + data.id); + a.setAttribute('title', data.name); + a.textContent = data.name; + return a.outerHTML; + }; + + // A render function for batch status + var createStatus = function (x, y, data) { + return data.status.name; + }; + + // A render function for our patron link + var createPatronLink = function (data) { + var link = document.createElement('a'); + link.setAttribute('title', ill_batch_borrower_details); + link.setAttribute('href', '/cgi-bin/koha/members/moremember.pl?borrowernumber=' + data.borrowernumber); + var displayText = [data.firstname, data.surname].join(' ') + ' ( ' + data.cardnumber + ' )'; + link.appendChild(document.createTextNode(displayText)); + + return link.outerHTML; + }; + + // A render function for our row action buttons + var createActions = function (data, type, row) { + var div = document.createElement('div'); + div.setAttribute('class', 'action-buttons'); + + var editButton = document.createElement('button'); + editButton.setAttribute('type', 'button'); + editButton.setAttribute('class', 'editButton btn btn-xs btn-default'); + editButton.setAttribute('data-batch-id', row.id); + editButton.appendChild(document.createTextNode(ill_batch_edit)); + + var deleteButton = document.createElement('button'); + deleteButton.setAttribute('type', 'button'); + deleteButton.setAttribute('class', 'deleteButton btn btn-xs btn-danger'); + deleteButton.setAttribute('data-batch-id', row.id); + deleteButton.appendChild(document.createTextNode(ill_batch_delete)); + + div.appendChild(editButton); + div.appendChild(deleteButton); + + return div.outerHTML; + }; + + // Add event listeners to our row action buttons + var addEventListeners = function () { + var del = document.querySelectorAll('.deleteButton'); + del.forEach(function (el) { + el.addEventListener('click', handleDeleteClick); + }); + + var edit = document.querySelectorAll('.editButton'); + edit.forEach(function (elEdit) { + elEdit.addEventListener('click', handleEditClick); + }); + }; + + // Remove all added event listeners + var removeEventListeners = function () { + var del = document.querySelectorAll('.deleteButton'); + del.forEach(function (el) { + el.removeEventListener('click', handleDeleteClick); + }); + window.removeEventListener('load', onload); + window.removeEventListener('beforeunload', removeEventListeners); + }; + + // Handle "Delete" clicks + var handleDeleteClick = function(e) { + var el = e.srcElement; + if (confirm(ill_batch_confirm_delete)) { + deleteBatch(el); + } + }; + + // Handle "Edit" clicks + var handleEditClick = function(e) { + var el = e.srcElement; + var id = el.dataset.batchId; + window.openBatchModal(id); + }; + + // Delete a batch + // - Make the API call + // - Handle errors + // - Update our proxy data + var deleteBatch = function (el) { + var id = el.dataset.batchId; + doBatchApiRequest( + '/' + id, + { method: 'DELETE' } + ) + .then(function (response) { + if (!response.ok) { + window.handleApiError(ill_batch_delete_fail); + } else { + removeBatch(el.dataset.batchId); + } + }) + .catch(function (response) { + window.handleApiError(ill_batch_delete_fail); + }) + }; + + // Remove a batch from our proxy data + var removeBatch = function(id) { + batchesProxy.data = batchesProxy.data.filter(function (batch) { + return batch.id != id; + }); + }; + + // Redraw the table + var updateTable = function () { + table.api() + .clear() + .rows.add(batchesProxy.data) + .draw(); + }; + +})(); diff --git a/koha-tmpl/intranet-tmpl/prog/js/ill-batch.js b/koha-tmpl/intranet-tmpl/prog/js/ill-batch.js new file mode 100644 index 0000000000..491c0a4374 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/ill-batch.js @@ -0,0 +1,49 @@ +(function () { + // Enable the modal to be opened from anywhere + // If we're working with an existing batch, set the ID so the + // modal can access it + window.openBatchModal = function (id, backend) { + var idEl = document.getElementById('ill-batch-details'); + idEl.dataset.backend = backend; + if (id) { + idEl.dataset.batchId = id; + } + $('#ill-batch-modal').modal({ show: true }); + }; + + // Make a batch API call, returning the resulting promise + window.doBatchApiRequest = function (url, options) { + var batchListApi = '/api/v1/illbatches'; + var fullUrl = batchListApi + (url ? url : ''); + return doApiRequest(fullUrl, options); + }; + + // Make a "create local ILL submission" call + window.doCreateSubmission = function (body, options) { + options = Object.assign( + options || {}, + { + headers: { + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(body) + } + ); + return doApiRequest( + '/api/v1/ill/requests', + options + ) + } + + // Make an API call, returning the resulting promise + window.doApiRequest = function (url, options) { + return fetch(url, options); + }; + + // Display an API error + window.handleApiError = function (error) { + alert(error); + }; + +})(); diff --git a/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js b/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js index 8a358015ac..23a798bf7a 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js +++ b/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js @@ -79,9 +79,14 @@ $(document).ready(function() { }, "me.borrowernumber": function(){ let borrowernumber_pre_filter = additional_prefilters.find(e => e.key === 'borrowernumber'); - if ( additional_prefilters.length == 0 || typeof borrowernumber_pre_filter === undefined) return ""; + if ( additional_prefilters.length == 0 || typeof borrowernumber_pre_filter === "undefined") return ""; return { "=": borrowernumber_pre_filter["value"] } }, + "me.batch_id": function(){ + let batch_pre_filter = additional_prefilters.find(e => e.key === 'batch_id'); + if ( additional_prefilters.length == 0 || typeof batch_pre_filter === "undefined") return ""; + return { "=": batch_pre_filter["value"] } + }, "-or": function(){ let patron = $("#illfilter_patron").val(); let status = $("#illfilter_status").val(); @@ -187,6 +192,7 @@ $(document).ready(function() { 'biblio', 'comments+count', 'extended_attributes', + 'batch', 'library', 'id_prefix', 'patron' @@ -204,6 +210,13 @@ $(document).ready(function() { '">' + escape_str(row.id_prefix) + escape_str(data) + ''; } }, + { + "data": "", // batch + "orderable": false, + "render": function(data, type, row, meta) { + return '' + row.batch.name + ''; + } + }, { "data": "", // author "orderable": false, -- 2.30.2