From 81bb1e895a7953568489888a727e7f2486876281 Mon Sep 17 00:00:00 2001 From: Pedro Amorim Date: Thu, 10 Jul 2025 11:30:52 +0000 Subject: [PATCH] Bug 40517: REST API: Hold groups endpoint list and add actions: /patrons/{patron_id}/hold_groups New test file: prove t/db_dependent/api/v1/patrons_hold_groups.t --- Koha/HoldGroup.pm | 32 ++++ Koha/REST/V1/Patrons/HoldGroups.pm | 129 +++++++++++++ api/v1/swagger/definitions/hold_group.yaml | 16 ++ api/v1/swagger/paths/patrons_hold_groups.yaml | 155 ++++++++++++++++ api/v1/swagger/swagger.yaml | 4 + t/db_dependent/api/v1/patrons_hold_groups.t | 173 ++++++++++++++++++ 6 files changed, 509 insertions(+) create mode 100644 Koha/REST/V1/Patrons/HoldGroups.pm create mode 100644 api/v1/swagger/definitions/hold_group.yaml create mode 100644 api/v1/swagger/paths/patrons_hold_groups.yaml create mode 100755 t/db_dependent/api/v1/patrons_hold_groups.t diff --git a/Koha/HoldGroup.pm b/Koha/HoldGroup.pm index 08675c48eba..7a7ae302408 100644 --- a/Koha/HoldGroup.pm +++ b/Koha/HoldGroup.pm @@ -31,6 +31,38 @@ Koha::HoldGroup - Koha Hold Group object class =cut +=head3 to_api + + my $json = $hold_group->to_api; + +Overloaded method that returns a JSON representation of the Koha::HoldGroup object, +suitable for API output. The related Koha::Holds objects are merged as expected +on the API. + +=cut + +sub to_api { + my ( $self, $args ) = @_; + + my $json_hold_group = $self->SUPER::to_api($args); + return unless $json_hold_group; + + $args = defined $args ? {%$args} : {}; + delete $args->{embed}; + + my $holds = $self->holds; + + Koha::Exceptions::RelatedObjectNotFound->throw( accessor => 'holds', class => 'Koha::HoldGroup' ) + unless $holds; + + my @json_holds; + for my $hold ($holds) { + push @json_holds, $hold->to_api($args); + } + + return { %$json_hold_group, holds => @json_holds }; +} + =head3 holds $holds = $hold_group->holds diff --git a/Koha/REST/V1/Patrons/HoldGroups.pm b/Koha/REST/V1/Patrons/HoldGroups.pm new file mode 100644 index 00000000000..b5fa3a54ea7 --- /dev/null +++ b/Koha/REST/V1/Patrons/HoldGroups.pm @@ -0,0 +1,129 @@ +package Koha::REST::V1::Patrons::HoldGroups; + +# 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::HoldGroup; +use Koha::Patrons; + +use Try::Tiny; + +=head1 NAME + +Koha::REST::V1::Patrons::HoldGroups + +=head1 API + +=head2 Methods + +=head3 list + +Controller function that handles listing Koha::HoldGroup objects for the requested patron + +=cut + +sub list { + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->param('patron_id') ); + + return $c->render_resource_not_found("Patron") + unless $patron; + + return try { + my $hold_groups_set = $patron->hold_groups; + + my $hold_groups = $c->objects->search($hold_groups_set); + return $c->render( status => 200, openapi => $hold_groups ); + } catch { + $c->unhandled_exception($_); + }; +} + +=head3 add + +Controller function that handles adding Koha::HoldGroup objects for the requested patron + +=cut + +sub add { + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->param('patron_id') ); + + return $c->render_resource_not_found("Patron") + unless $patron; + + my $body = $c->req->json; + my $hold_ids = $body->{hold_ids}; + my $force_grouped = $body->{force_grouped} || 0; + + return try { + my $hold_group = $patron->create_hold_group( $hold_ids, $force_grouped ); + $c->res->headers->location( $c->req->url->to_string . '/' . $hold_group->hold_group_id ); + return $c->render( + status => 201, + openapi => $c->objects->to_api($hold_group), + ); + } catch { + if ( blessed $_ ) { + if ( $_->isa('Koha::Exceptions::HoldGroup::HoldDoesNotExist') ) { + return $c->render( + status => 400, + openapi => { + error => $_->description . ": " . join( ', ', @{ $_->hold_ids } ), + error_code => 'HoldDoesNotExist', + hold_ids => $_->hold_ids + } + ); + } elsif ( $_->isa('Koha::Exceptions::HoldGroup::HoldDoesNotBelongToPatron') ) { + return $c->render( + status => 400, + openapi => { + error => $_->description . ": " . join( ', ', @{ $_->hold_ids } ), + error_code => 'HoldDoesNotBelongToPatron', + hold_ids => $_->hold_ids + } + ); + } elsif ( $_->isa('Koha::Exceptions::HoldGroup::HoldHasAlreadyBeenFound') ) { + return $c->render( + status => 400, + openapi => { + error => $_->description . ": " . join( ', ', @{ $_->barcodes } ), + error_code => 'HoldHasAlreadyBeenFound', + barcodes => $_->barcodes + } + ); + } elsif ( $_->isa('Koha::Exceptions::HoldGroup::HoldAlreadyBelongsToHoldGroup') ) { + return $c->render( + status => 400, + openapi => { + error => $_->description . ": " . join( ', ', @{ $_->hold_ids } ), + error_code => 'HoldAlreadyBelongsToHoldGroup', + hold_ids => $_->hold_ids + } + ); + + } + } + $c->unhandled_exception($_); + }; +} + +1; diff --git a/api/v1/swagger/definitions/hold_group.yaml b/api/v1/swagger/definitions/hold_group.yaml new file mode 100644 index 00000000000..cb9d34cc88e --- /dev/null +++ b/api/v1/swagger/definitions/hold_group.yaml @@ -0,0 +1,16 @@ +--- +type: object +properties: + hold_group_id: + type: integer + description: Internal hold group identifier + borrowernumber: + type: integer + description: Patron this hold group belongs to + holds: + type: + - array + - "null" + description: The holds in this hold group (x-koha-embed) + +additionalProperties: false diff --git a/api/v1/swagger/paths/patrons_hold_groups.yaml b/api/v1/swagger/paths/patrons_hold_groups.yaml new file mode 100644 index 00000000000..49e6129f028 --- /dev/null +++ b/api/v1/swagger/paths/patrons_hold_groups.yaml @@ -0,0 +1,155 @@ +--- +"/patrons/{patron_id}/hold_groups": + get: + x-mojo-to: Patrons::HoldGroups#list + operationId: getPatronHoldGroups + tags: + - hold_groups + summary: List hold groups for a patron + parameters: + - $ref: "../swagger.yaml#/parameters/patron_id_pp" + - $ref: "../swagger.yaml#/parameters/match" + - $ref: "../swagger.yaml#/parameters/order_by" + - $ref: "../swagger.yaml#/parameters/page" + - $ref: "../swagger.yaml#/parameters/per_page" + - $ref: "../swagger.yaml#/parameters/q_param" + - $ref: "../swagger.yaml#/parameters/q_body" + - $ref: "../swagger.yaml#/parameters/request_id_header" + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - holds + collectionFormat: csv + produces: + - application/json + responses: + "200": + description: The patron hold groups + schema: + type: array + items: + $ref: "../swagger.yaml#/definitions/hold_group" + "400": + description: | + Bad request. Possible `error_code` attribute values: + + * `invalid_query` + 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 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: + borrowers: edit_borrowers + post: + x-mojo-to: Patrons::HoldGroups#add + operationId: addPatronHoldGroups + tags: + - hold_groups + summary: Add hold group. May contain hold ids of existing holds + parameters: + - name: body + in: body + description: A JSON object containing informations about the new hold group + required: true + schema: + type: object + properties: + hold_ids: + description: List of holds to add to hold group + type: + - array + - "null" + force_grouped: + description: Whether to force creation of a new hold group + type: + - boolean + - "null" + additionalProperties: false + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - holds + collectionFormat: csv + consumes: + - application/json + produces: + - application/json + responses: + "201": + description: Created hold group + schema: + $ref: "../swagger.yaml#/definitions/hold_group" + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Hold not allowed + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Borrower not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "409": + description: | + Conflict. Possible `error_code` attribute values: + + * `bad_address` + * `card_lost` + * `debt_limit` + * `expired` + * `hold_limit` + * `restricted` + 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: + reserveforothers: "1" \ No newline at end of file diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index cd9c5794046..f99cf2eecd9 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -98,6 +98,8 @@ definitions: $ref: ./definitions/hold.yaml holds: $ref: ./definitions/holds.yaml + hold_group: + $ref: ./definitions/hold_group.yaml ill_backend: $ref: ./definitions/ill_backend.yaml ill_backends: @@ -481,6 +483,8 @@ paths: $ref: "./paths/patrons_extended_attributes.yaml#/~1patrons~1{patron_id}~1extended_attributes~1{extended_attribute_id}" "/patrons/{patron_id}/holds": $ref: "./paths/patrons_holds.yaml#/~1patrons~1{patron_id}~1holds" + "/patrons/{patron_id}/hold_groups": + $ref: "./paths/patrons_hold_groups.yaml#/~1patrons~1{patron_id}~1hold_groups" "/patrons/{patron_id}/checkouts": $ref: "./paths/patrons_checkouts.yaml#/~1patrons~1{patron_id}~1checkouts" "/patrons/{patron_id}/password": diff --git a/t/db_dependent/api/v1/patrons_hold_groups.t b/t/db_dependent/api/v1/patrons_hold_groups.t new file mode 100755 index 00000000000..2cb39817b46 --- /dev/null +++ b/t/db_dependent/api/v1/patrons_hold_groups.t @@ -0,0 +1,173 @@ +#!/usr/bin/env perl + +# 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 Test::NoWarnings; +use Test::More tests => 3; +use Test::MockModule; +use Test::Mojo; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new(); + +my $t = Test::Mojo->new('Koha::REST::V1'); + +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); + +subtest 'list() tests' => sub { + + plan tests => 6; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 2**4 } # 'borrowers' flag == 4 + } + ); + my $password = 'thePassword123'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $patron->userid; + + $t->get_ok( "//$userid:$password@/api/v1/patrons/" . $patron->id . '/hold_groups' )->status_is( 200, 'REST3.2.2' ) + ->json_is( [] ); + + my $hold_group_1 = $builder->build_object( + { + class => 'Koha::HoldGroups', + value => { borrowernumber => $patron->id } + } + ); + my $hold_1 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $patron->id, hold_group_id => $hold_group_1->hold_group_id } + } + ); + my $hold_2 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $patron->id, hold_group_id => $hold_group_1->hold_group_id } + } + ); + my $hold_3 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $patron->id, hold_group_id => $hold_group_1->hold_group_id } + } + ); + + $t->get_ok( "//$userid:$password@/api/v1/patrons/" . $patron->id . '/hold_groups' => { 'x-koha-embed' => 'holds' } ) + ->status_is( 200, 'REST3.2.2' )->json_has( "/0/holds", "holds object correctly embedded" ); + + $schema->storage->txn_rollback; +}; + +subtest 'add() tests' => sub { + + plan tests => 15; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 2**6 } # reserveforothers flag = 6 + } + ); + my $other_patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 2**6 } # reserveforothers flag = 6 + } + ); + my $password = 'thePassword123'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $patron->userid; + + my $hold_1 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $patron->id, hold_group_id => undef, found => undef } + } + ); + my $hold_2 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $patron->id, hold_group_id => undef, found => undef } + } + ); + my $hold_3 = $builder->build_object( + { + class => 'Koha::Holds', + value => { borrowernumber => $other_patron->id, hold_group_id => undef, found => 'W' } + } + ); + + $t->post_ok( + "//$userid:$password@/api/v1/patrons/" . $patron->id . "/hold_groups" => json => { hold_ids => [333] } ) + ->status_is( 400, 'REST3.2.1' ) + ->json_is( + { error => 'One or more holds do not exist: 333', error_code => "HoldDoesNotExist", hold_ids => [333] } ); + + $t->post_ok( "//$userid:$password@/api/v1/patrons/" + . $other_patron->id + . "/hold_groups" => json => { hold_ids => [ $hold_3->reserve_id ] } )->status_is( 400, 'REST3.2.1' ) + ->json_is( + { + error => 'One or more holds have already been found: ' . $hold_3->item->barcode, + error_code => "HoldHasAlreadyBeenFound", barcodes => [ $hold_3->item->barcode ] + } + ); + + $t->post_ok( "//$userid:$password@/api/v1/patrons/" + . $patron->id + . "/hold_groups" => json => { hold_ids => [ $hold_3->reserve_id ] } )->status_is( 400, 'REST3.2.1' ) + ->json_is( + { + error => 'One or more holds do not belong to patron: ' . $hold_3->reserve_id, + error_code => "HoldDoesNotBelongToPatron", hold_ids => [ $hold_3->reserve_id ] + } + ); + + $t->post_ok( "//$userid:$password@/api/v1/patrons/" + . $patron->id + . "/hold_groups" => json => { hold_ids => [ $hold_1->reserve_id, $hold_2->reserve_id ] } ) + ->status_is( 201, 'REST3.2.1' )->json_has( "/holds", "holds object correctly embedded" ); + + $t->post_ok( "//$userid:$password@/api/v1/patrons/" + . $patron->id + . "/hold_groups" => json => { hold_ids => [ $hold_1->reserve_id, $hold_2->reserve_id ] } ) + ->status_is( 400, 'REST3.2.1' )->json_is( + { + error => "One or more holds already belong to a hold group: " + . $hold_1->reserve_id . ", " + . $hold_2->reserve_id, + error_code => "HoldAlreadyBelongsToHoldGroup", + hold_ids => [ $hold_1->reserve_id, $hold_2->reserve_id ] + } + ); + + $schema->storage->txn_rollback; +}; -- 2.39.5