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

Signed-off-by: Anneli <anneli.osterman@koha-suomi.fi>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Pedro Amorim 2025-07-10 11:30:52 +00:00 committed by Lucas Gass
parent 07275db04f
commit dada9dad0f
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
6 changed files with 509 additions and 0 deletions

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses>.
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;

View File

@ -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

View File

@ -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"

View File

@ -100,6 +100,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:
@ -495,6 +497,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":

View File

@ -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 <http://www.gnu.org/licenses>.
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;
};