From a902efb83b203f2f99938388acd88f3fc059422a Mon Sep 17 00:00:00 2001 From: Agustin Moyano Date: Tue, 10 Sep 2019 03:20:38 -0300 Subject: [PATCH] Bug 19618: Add api endpoint for club holds This patch adds an endpoint in thi api in /api/v1/clubs/{club_id}/holds whith the verb POST that maps to Koha::REST::V1::Clubs::Holds#add controller. Classes for club_holds and club_holds_to_patron_holds new tables where also added. To test: 1) Reach SUCCESS.3 test point of previous patch with club that has no enrollments 2) Click on "Place Hold" SUCCESS => an alert should appear that you cannot place hold on a club without patrons 3) Reach SUCCESS.3 test point of previous patch with club that has enrollments 4) Click on "Place Hold" SUCCESS => holds priority list should appear with holds for every patron in club 5) Repeat steps 3 and 4. SUCCESS => new holds should appear in different order 6) Sign off Sponsored-by: Southeast Kansas Library - SEKLS Signed-off-by: Martin Renvoize Signed-off-by: Kyle M Hall Signed-off-by: Martin Renvoize --- Koha/Club/Hold.pm | 142 ++++++++++++ Koha/Club/Hold/PatronHold.pm | 56 +++++ Koha/Club/Hold/PatronHolds.pm | 64 ++++++ Koha/Club/Holds.pm | 64 ++++++ Koha/Exceptions/ClubHold.pm | 15 ++ Koha/REST/V1/Clubs/Holds.pm | 209 ++++++++++++++++++ api/v1/swagger/definitions/club_hold.json | 21 ++ .../definitions/club_hold_patron_hold.json | 30 +++ .../definitions/club_hold_patron_holds.json | 6 + api/v1/swagger/definitions/club_holds.json | 6 + api/v1/swagger/parameters.json | 3 + api/v1/swagger/parameters/club.json | 9 + api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/clubs.json | 100 +++++++++ .../prog/en/modules/reserve/request.tt | 2 +- 15 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 Koha/Club/Hold.pm create mode 100644 Koha/Club/Hold/PatronHold.pm create mode 100644 Koha/Club/Hold/PatronHolds.pm create mode 100644 Koha/Club/Holds.pm create mode 100644 Koha/Exceptions/ClubHold.pm create mode 100644 Koha/REST/V1/Clubs/Holds.pm create mode 100644 api/v1/swagger/definitions/club_hold.json create mode 100644 api/v1/swagger/definitions/club_hold_patron_hold.json create mode 100644 api/v1/swagger/definitions/club_hold_patron_holds.json create mode 100644 api/v1/swagger/definitions/club_holds.json create mode 100644 api/v1/swagger/parameters/club.json create mode 100644 api/v1/swagger/paths/clubs.json diff --git a/Koha/Club/Hold.pm b/Koha/Club/Hold.pm new file mode 100644 index 0000000000..9ca8f40456 --- /dev/null +++ b/Koha/Club/Hold.pm @@ -0,0 +1,142 @@ +package Koha::Club::Hold; + +# Copyright ByWater Solutions 2014 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Club::Template::Fields; + +use base qw(Koha::Object); +use Koha::Exceptions::ClubHold; +use Koha::Club::Hold::PatronHold; +use Koha::Clubs; + +use List::Util 'shuffle'; + +=head1 NAME + +Koha::Club::Hold + +Represents a hold made for every member of club + +=head1 API + +=head2 Class Methods + +=cut + +=head3 add + +Class (static) method that returns a new Koha::Club::Hold instance + +=cut + +sub add { + my ( $params ) = @_; + + throw Koha::Exceptions::ClubHold unless $params->{club_id} && $params->{biblio_id}; + my $club = Koha::Clubs->find($params->{club_id}); + my @enrollments = $club->club_enrollments->as_list; + throw Koha::Exceptions::ClubHold::NoPatrons() unless scalar @enrollments; + + my $biblio = Koha::Biblios->find($params->{biblio_id}); + + my $club_params = { + club_id => $params->{club_id}, + biblio_id => $params->{biblio_id}, + item_id => $params->{item_id} + }; + + my $club_hold = Koha::Club::Hold->new($club_params)->store(); + + @enrollments = shuffle(@enrollments); + + foreach my $enrollment (@enrollments) { + my $patron_id = $enrollment->borrowernumber; + + my $can_place_hold + = $params->{item_id} + ? C4::Reserves::CanItemBeReserved( $patron_id, $params->{club_id} ) + : C4::Reserves::CanBookBeReserved( $patron_id, $params->{biblio_id} ); + + unless ( $can_place_hold->{status} eq 'OK' ) { + warn "Patron(".$patron_id.") Hold cannot be placed. Reason: " . $can_place_hold->{status}; + Koha::Club::Hold::PatronHold->new({ + patron_id => $patron_id, + club_hold_id => $club_hold->id, + error_code => $can_place_hold->{status} + })->store(); + next; + } + + my $priority = C4::Reserves::CalculatePriority($params->{biblio_id}); + + my $hold_id = C4::Reserves::AddReserve( + $params->{pickup_library_id}, + $patron_id, + $params->{biblio_id}, + undef, # $bibitems param is unused + $priority, + undef, # hold date, we don't allow it currently + $params->{expiration_date}, + $params->{notes}, + $biblio->title, + $params->{item_id}, + undef, # TODO: Why not? + $params->{item_type} + ); + if ($hold_id) { + Koha::Club::Hold::PatronHold->new({ + patron_id => $patron_id, + club_hold_id => $club_hold->id, + hold_id => $hold_id + })->store(); + } else { + warn "Could not create hold for Patron(".$patron_id.")"; + Koha::Club::Hold::PatronHold->new({ + patron_id => $patron_id, + club_hold_id => $club_hold->id, + error_message => "Could not create hold for Patron(".$patron_id.")" + })->store(); + } + + } + + return $club_hold; + +} + +=head3 type + +=cut + +sub _type { + return 'ClubHold'; +} + +=head1 AUTHOR + +Agustin Moyano + +=cut + +1; \ No newline at end of file diff --git a/Koha/Club/Hold/PatronHold.pm b/Koha/Club/Hold/PatronHold.pm new file mode 100644 index 0000000000..339465f8da --- /dev/null +++ b/Koha/Club/Hold/PatronHold.pm @@ -0,0 +1,56 @@ +package Koha::Club::Hold::PatronHold; + +# Copyright ByWater Solutions 2014 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Club::Template::Fields; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::Club::Hold::PatronHold + +Represents an effective hold made for a patron, because of a club hold + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'ClubHoldsToPatronHold'; +} + +=head1 AUTHOR + +Agustin Moyano + +=cut + +1; \ No newline at end of file diff --git a/Koha/Club/Hold/PatronHolds.pm b/Koha/Club/Hold/PatronHolds.pm new file mode 100644 index 0000000000..40f43616d0 --- /dev/null +++ b/Koha/Club/Hold/PatronHolds.pm @@ -0,0 +1,64 @@ +package Koha::Club::Hold::PatronHolds; + +# Copyright ByWater Solutions 2014 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Club::Field; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Club::Hold::PatronHolds + +Represents a collection of effective holds made by a club hold. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'ClubHoldsToPatronHold'; +} + +=head3 object_class + +=cut + +sub object_class { + return 'Koha::Club::Hold::PatronHold'; +} + +=head1 AUTHOR + +Agustin Moyano + +=cut + +1; \ No newline at end of file diff --git a/Koha/Club/Holds.pm b/Koha/Club/Holds.pm new file mode 100644 index 0000000000..468223f10c --- /dev/null +++ b/Koha/Club/Holds.pm @@ -0,0 +1,64 @@ +package Koha::Club::Holds; + +# Copyright ByWater Solutions 2014 +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Club::Field; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Club::Holds + +Represents a collection of club holds. + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'ClubHold'; +} + +=head3 object_class + +=cut + +sub object_class { + return 'Koha::Club::Hold'; +} + +=head1 AUTHOR + +Agustin Moyano + +=cut + +1; \ No newline at end of file diff --git a/Koha/Exceptions/ClubHold.pm b/Koha/Exceptions/ClubHold.pm new file mode 100644 index 0000000000..429f89c0bd --- /dev/null +++ b/Koha/Exceptions/ClubHold.pm @@ -0,0 +1,15 @@ +package Koha::Exceptions::ClubHold; + +use Modern::Perl; + +use Exception::Class ( + 'Koha::Exceptions::ClubHold' => { + description => "Something went wrong!", + }, + 'Koha::Exceptions::ClubHold::NoPatrons' => { + isa => 'Koha::Exceptions::ClubHold', + description => "Cannot place a hold on a club without patrons.", + }, +); + +1; \ No newline at end of file diff --git a/Koha/REST/V1/Clubs/Holds.pm b/Koha/REST/V1/Clubs/Holds.pm new file mode 100644 index 0000000000..5d25bd3c5f --- /dev/null +++ b/Koha/REST/V1/Clubs/Holds.pm @@ -0,0 +1,209 @@ +package Koha::REST::V1::Clubs::Holds; + +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use C4::Biblio; +use C4::Reserves; + +use Koha::Items; +use Koha::Patrons; +use Koha::Holds; +use Koha::Clubs; +use Koha::Club::Hold; +use Koha::DateUtils; + +use Scalar::Util qw(blessed); +use Try::Tiny; +use List::Util 'shuffle'; + +=head1 API + +=head2 Class methods + +=head3 add + +Method that handles adding a new Koha::Hold object + +=cut + +sub add { + my $c = shift->openapi->valid_input or return; + + return try { + my $body = $c->validation->param('body'); + my $club_id = $c->validation->param('club_id'); + + my $biblio; + + my $biblio_id = $body->{biblio_id}; + my $pickup_library_id = $body->{pickup_library_id}; + my $item_id = $body->{item_id}; + my $item_type = $body->{item_type}; + my $expiration_date = $body->{expiration_date}; + my $notes = $body->{notes}; + + if ( $item_id and $biblio_id ) { + + # check they are consistent + unless ( Koha::Items->search( { itemnumber => $item_id, biblionumber => $biblio_id } ) + ->count > 0 ) + { + return $c->render( + status => 400, + openapi => { error => "Item $item_id doesn't belong to biblio $biblio_id" } + ); + } + else { + $biblio = Koha::Biblios->find($biblio_id); + } + } + elsif ($item_id) { + my $item = Koha::Items->find($item_id); + + unless ($item) { + return $c->render( + status => 404, + openapi => { error => "item_id not found." } + ); + } + else { + $biblio = $item->biblio; + } + } + elsif ($biblio_id) { + $biblio = Koha::Biblios->find($biblio_id); + } + else { + return $c->render( + status => 400, + openapi => { error => "At least one of biblio_id, item_id should be given" } + ); + } + + unless ($biblio) { + return $c->render( + status => 400, + openapi => "Biblio not found." + ); + } + + # AddReserve expects date to be in syspref format + if ($expiration_date) { + $expiration_date = output_pref( dt_from_string( $expiration_date, 'rfc3339' ) ); + } + + my $club_hold = Koha::Club::Hold::add({ + club_id => $club_id, + biblio_id => $biblio->biblionumber, + item_id => $item_id, + pickup_library_id => $pickup_library_id, + expiration_date => $expiration_date, + notes => $notes, + item_type => $item_type + }); + + my $mapping = _to_api($club_hold->unblessed); + + return $c->render( status => 201, openapi => $mapping ); + } + catch { + if ( blessed $_ and $_->isa('Koha::Exceptions::Object') ) { + if ( $_->isa('Koha::Exceptions::Object::FKConstraint') ) { + my $broken_fk = $_->broken_fk; + + if ( grep { $_ eq $broken_fk } keys %{$Koha::REST::V1::Clubs::Holds::to_api_mapping} ) { + $c->render( + status => 404, + openapi => $Koha::REST::V1::Clubs::Holds::to_api_mapping->{$broken_fk} . ' not found.' + ); + } + else { + return $c->render( + status => 500, + openapi => { error => "Uncaught exception: $_" } + ); + } + } + else { + return $c->render( + status => 500, + openapi => { error => "$_" } + ); + } + } + elsif (blessed $_ and $_->isa('Koha::Exceptions::ClubHold')) { + return $c->render( + status => 500, + openapi => { error => $_->description } + ); + } + else { + return $c->render( + status => 500, + openapi => { error => "Something went wrong. check the logs." } + ); + } + }; +} + +=head3 _to_api + +Helper function that maps unblessed Koha::Club::Hold objects into REST api +attribute names. + +=cut + +sub _to_api { + my $club_hold = shift; + + # Rename attributes + foreach my $column ( keys %{ $Koha::REST::V1::Clubs::Holds::to_api_mapping } ) { + my $mapped_column = $Koha::REST::V1::Clubs::Holds::to_api_mapping->{$column}; + if ( exists $club_hold->{ $column } + && defined $mapped_column ) + { + # key != undef + $club_hold->{ $mapped_column } = delete $club_hold->{ $column }; + } + elsif ( exists $club_hold->{ $column } + && !defined $mapped_column ) + { + # key == undef + delete $club_hold->{ $column }; + } + } + + # Calculate the 'restricted' field + return $club_hold; +} + +=head3 $to_api_mapping + +=cut + +our $to_api_mapping = { + id => 'club_hold_id', + club_id => 'club_id', + biblio_id => 'biblio_id', + item_id => 'item_id' +}; + + +1; diff --git a/api/v1/swagger/definitions/club_hold.json b/api/v1/swagger/definitions/club_hold.json new file mode 100644 index 0000000000..5a270bc5f1 --- /dev/null +++ b/api/v1/swagger/definitions/club_hold.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "club_hold_id": { + "type": "integer", + "description": "Internal club hold identifier" + }, + "club_id": { + "type": "integer", + "description": "Internal club identifier" + }, + "biblio_id": { + "type": "integer", + "description": "Internal biblio identifier" + }, + "item_id": { + "type": ["string", "null"], + "description": "Internal item identifier" + } + } + } diff --git a/api/v1/swagger/definitions/club_hold_patron_hold.json b/api/v1/swagger/definitions/club_hold_patron_hold.json new file mode 100644 index 0000000000..1f338149c0 --- /dev/null +++ b/api/v1/swagger/definitions/club_hold_patron_hold.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "club_hold_patron_hold_id": { + "type": "integer", + "description": "Internal club hold to patron hold identifier" + }, + "club_hold_id": { + "type": "integer", + "description": "Internal club hold identifier" + }, + "hold_id": { + "type": ["integer", "null"], + "description": "Internal hold identifier" + }, + "patron_id": { + "type": "integer", + "description": "Internal patron identifier" + }, + "error_code": { + "type": ["string", "null"], + "format": "date", + "description": "Code returned by CanItemBeReserved" + }, + "error_message": { + "type": ["string", "null"], + "description": "Generic error message" + } + } + } diff --git a/api/v1/swagger/definitions/club_hold_patron_holds.json b/api/v1/swagger/definitions/club_hold_patron_holds.json new file mode 100644 index 0000000000..ff7b6e506d --- /dev/null +++ b/api/v1/swagger/definitions/club_hold_patron_holds.json @@ -0,0 +1,6 @@ +{ + "type": "array", + "items": { + "$ref": "club_hold_patron_hold.json" + } +} \ No newline at end of file diff --git a/api/v1/swagger/definitions/club_holds.json b/api/v1/swagger/definitions/club_holds.json new file mode 100644 index 0000000000..b484840f94 --- /dev/null +++ b/api/v1/swagger/definitions/club_holds.json @@ -0,0 +1,6 @@ +{ + "type": "array", + "items": { + "$ref": "club_hold.json" + } +} \ No newline at end of file diff --git a/api/v1/swagger/parameters.json b/api/v1/swagger/parameters.json index b5aa33ecbb..932d251d42 100644 --- a/api/v1/swagger/parameters.json +++ b/api/v1/swagger/parameters.json @@ -14,6 +14,9 @@ "hold_id_pp": { "$ref": "parameters/hold.json#/hold_id_pp" }, + "club_id_pp": { + "$ref": "parameters/club.json#/club_id_pp" + }, "library_id_pp": { "$ref": "parameters/library.json#/library_id_pp" }, diff --git a/api/v1/swagger/parameters/club.json b/api/v1/swagger/parameters/club.json new file mode 100644 index 0000000000..08e15d7079 --- /dev/null +++ b/api/v1/swagger/parameters/club.json @@ -0,0 +1,9 @@ +{ + "club_id_pp": { + "name": "club_id", + "in": "path", + "description": "Internal club identifier", + "required": true, + "type": "integer" + } +} \ No newline at end of file diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index 0264c0f0c1..d705760f63 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -29,6 +29,9 @@ "/biblios/{biblio_id}": { "$ref": "paths/biblios.json#/~1biblios~1{biblio_id}" }, + "/clubs/{club_id}/holds": { + "$ref": "paths/clubs.json#/~1clubs~1{club_id}~1holds" + }, "/holds": { "$ref": "paths/holds.json#/~1holds" }, diff --git a/api/v1/swagger/paths/clubs.json b/api/v1/swagger/paths/clubs.json new file mode 100644 index 0000000000..2f4498a442 --- /dev/null +++ b/api/v1/swagger/paths/clubs.json @@ -0,0 +1,100 @@ +{ + "/clubs/{club_id}/holds": { + "post": { + "x-mojo-to": "Clubs::Holds#add", + "operationId": "addClubHold", + "tags": ["holds", "clubs"], + "parameters": [{ + "$ref": "../parameters.json#/club_id_pp" + }, { + "name": "body", + "in": "body", + "description": "A JSON object containing informations about the new hold", + "required": true, + "schema": { + "type": "object", + "properties": { + "biblio_id": { + "description": "Internal biblio identifier", + "type": [ "integer", "null" ] + }, + "item_id": { + "description": "Internal item identifier", + "type": [ "integer", "null" ] + }, + "pickup_library_id": { + "description": "Internal library identifier for the pickup library", + "type": "string" + }, + "expiration_date": { + "description": "Hold end date", + "type": ["string", "null"], + "format": "date" + }, + "notes": { + "description": "Notes related to this hold", + "type": [ "string", "null" ] + }, + "item_type": { + "description": "Limit hold on one itemtype (ignored for item-level holds)", + "type": [ "string", "null" ] + } + }, + "required": [ "pickup_library_id" ] + } + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "201": { + "description": "Created hold", + "schema": { + "$ref": "../definitions/club_hold.json" + } + }, + "400": { + "description": "Missing or wrong parameters", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "401": { + "description": "Authentication required", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "403": { + "description": "Hold not allowed", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "Hold not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + }, + "x-koha-authorization": { + "permissions": { + "reserveforothers": "1" + } + } + } + } +} \ No newline at end of file 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 5b0c482c20..eef3c049aa 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/reserve/request.tt @@ -88,7 +88,7 @@ [% END %]
- +