From 5fcb02ca44f8f36b559a70762a84e58763090ceb Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Tue, 24 Mar 2015 11:30:00 +0100 Subject: [PATCH] Bug 13903: Add API routes to list, create, update, delete reserves GET /reserves?borrowernumber=X (list) POST /reserves (create) PUT /reserves/{reserve_id} (update) DELETE /reserves/{reserve_id} (delete) Unit tests in t/db_dependent/api/v1/reserves.t Test plan: 1. Apply patch 2. Run unit tests 3. Play with the API with your favorite REST client, using documentation in the swagger.json file 4. Try to make reserves until the maximum number of reserves for a user is reached (you should have a 403 error) Depends on bug 15126 Signed-off-by: Benjamin Rokseth Signed-off-by: Jesse Weaver --- Koha/REST/V1/Reserve.pm | 161 ++++++++++++++++++++++++++++++++++++++ api/v1/definitions/index.json | 2 + api/v1/definitions/reserve.json | 63 +++++++++++++++ api/v1/definitions/reserves.json | 4 + api/v1/swagger.json | 165 +++++++++++++++++++++++++++++++++++++++ t/db_dependent/api/v1/reserves.t | 154 ++++++++++++++++++++++++++++++++++++ 6 files changed, 549 insertions(+) create mode 100644 Koha/REST/V1/Reserve.pm create mode 100644 api/v1/definitions/reserve.json create mode 100644 api/v1/definitions/reserves.json create mode 100644 t/db_dependent/api/v1/reserves.t diff --git a/Koha/REST/V1/Reserve.pm b/Koha/REST/V1/Reserve.pm new file mode 100644 index 0000000..69e6e98 --- /dev/null +++ b/Koha/REST/V1/Reserve.pm @@ -0,0 +1,161 @@ +package Koha::REST::V1::Reserve; + +# 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::Patrons; +use Koha::DateUtils; + +sub list { + my ($c, $args, $cb) = @_; + + my $borrowernumber = $c->param('borrowernumber'); + my $borrower = Koha::Patrons->find($borrowernumber); + unless ($borrower) { + return $c->$cb({error => "Borrower not found"}, 404); + } + + my @reserves = C4::Reserves::GetReservesFromBorrowernumber($borrowernumber); + + return $c->$cb(\@reserves, 200); +} + +sub add { + my ($c, $args, $cb) = @_; + + my $body = $c->req->json; + + my $borrowernumber = $body->{borrowernumber}; + my $biblionumber = $body->{biblionumber}; + my $itemnumber = $body->{itemnumber}; + my $branchcode = $body->{branchcode}; + my $expirationdate = $body->{expirationdate}; + my $borrower = Koha::Patrons->find($borrowernumber); + unless ($borrower) { + return $c->$cb({error => "Borrower not found"}, 404); + } + + unless ($biblionumber or $itemnumber) { + return $c->$cb({ + error => "At least one of biblionumber, itemnumber should be given" + }, 400); + } + unless ($branchcode) { + return $c->$cb({ + error => "Branchcode is required" + }, 400); + } + + if ($itemnumber) { + my $item_biblionumber = C4::Biblio::GetBiblionumberFromItemnumber($itemnumber); + if ($biblionumber and $biblionumber != $item_biblionumber) { + return $c->$cb({ + error => "Item $itemnumber doesn't belong to biblio $biblionumber" + }, 400); + } + $biblionumber ||= $item_biblionumber; + } + + my $biblio = C4::Biblio::GetBiblio($biblionumber); + + my $can_reserve = + $itemnumber + ? CanItemBeReserved( $borrowernumber, $itemnumber ) + : CanBookBeReserved( $borrowernumber, $biblionumber ); + + unless ($can_reserve eq 'OK') { + return $c->$cb({ + error => "Reserve cannot be placed. Reason: $can_reserve" + }, 403); + } + + my $priority = C4::Reserves::CalculatePriority($biblionumber); + $itemnumber ||= undef; + + # AddReserve expects date to be in syspref format + if ($expirationdate) { + $expirationdate = output_pref(dt_from_string($expirationdate, 'iso')); + } + + my $reserve_id = C4::Reserves::AddReserve($branchcode, $borrowernumber, + $biblionumber, undef, $priority, undef, $expirationdate, undef, + $biblio->{title}, $itemnumber); + + unless ($reserve_id) { + return $c->$cb({ + error => "Error while placing reserve. See Koha logs for details." + }, 500); + } + + my $reserve = C4::Reserves::GetReserve($reserve_id); + + return $c->$cb($reserve, 201); +} + +sub edit { + my ($c, $args, $cb) = @_; + + my $reserve_id = $args->{reserve_id}; + my $reserve = C4::Reserves::GetReserve($reserve_id); + + unless ($reserve) { + return $c->$cb({error => "Reserve not found"}, 404); + } + + my $body = $c->req->json; + + my $branchcode = $body->{branchcode}; + my $priority = $body->{priority}; + my $suspend_until = $body->{suspend_until}; + + if ($suspend_until) { + $suspend_until = output_pref(dt_from_string($suspend_until, 'iso')); + } + + my $params = { + reserve_id => $reserve_id, + branchcode => $branchcode, + rank => $priority, + suspend_until => $suspend_until, + }; + C4::Reserves::ModReserve($params); + $reserve = C4::Reserves::GetReserve($reserve_id); + + return $c->$cb($reserve, 200); +} + +sub delete { + my ($c, $args, $cb) = @_; + + my $reserve_id = $args->{reserve_id}; + my $reserve = C4::Reserves::GetReserve($reserve_id); + + unless ($reserve) { + return $c->$cb({error => "Reserve not found"}, 404); + } + + C4::Reserves::CancelReserve({ reserve_id => $reserve_id }); + + return $c->$cb({}, 200); +} + +1; diff --git a/api/v1/definitions/index.json b/api/v1/definitions/index.json index e940eee..4e17693 100644 --- a/api/v1/definitions/index.json +++ b/api/v1/definitions/index.json @@ -1,4 +1,6 @@ { "patron": { "$ref": "patron.json" }, + "reserves": { "$ref": "reserves.json" }, + "reserve": { "$ref": "reserve.json" }, "error": { "$ref": "error.json" } } diff --git a/api/v1/definitions/reserve.json b/api/v1/definitions/reserve.json new file mode 100644 index 0000000..74370fe --- /dev/null +++ b/api/v1/definitions/reserve.json @@ -0,0 +1,63 @@ +{ + "type": "object", + "properties": { + "reserve_id": { + "description": "Internal reserve identifier" + }, + "borrowernumber": { + "type": "string", + "description": "internally assigned user identifier" + }, + "reservedate": { + "description": "the date the reserve was placed" + }, + "biblionumber": { + "type": "string", + "description": "internally assigned biblio identifier" + }, + "branchcode": { + "type": ["string", "null"], + "description": "internally assigned branch identifier" + }, + "notificationdate": { + "description": "currently unused" + }, + "reminderdate": { + "description": "currently unused" + }, + "cancellationdate": { + "description": "the date the reserve was cancelled" + }, + "reservenotes": { + "description": "notes related to this reserve" + }, + "priority": { + "description": "where in the queue the patron sits" + }, + "found": { + "description": "a one letter code defining what the status of the reserve is after it has been confirmed" + }, + "timestamp": { + "description": "date and time the reserve was last updated" + }, + "itemnumber": { + "type": ["string", "null"], + "description": "internally assigned item identifier" + }, + "waitingdate": { + "description": "the date the item was marked as waiting for the patron at the library" + }, + "expirationdate": { + "description": "the date the reserve expires" + }, + "lowestPriority": { + "description": "" + }, + "suspend": { + "description": "" + }, + "suspend_until": { + "description": "" + } + } +} diff --git a/api/v1/definitions/reserves.json b/api/v1/definitions/reserves.json new file mode 100644 index 0000000..431c444 --- /dev/null +++ b/api/v1/definitions/reserves.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "reserve.json" } +} diff --git a/api/v1/swagger.json b/api/v1/swagger.json index e821c44..b5c6d0a 100644 --- a/api/v1/swagger.json +++ b/api/v1/swagger.json @@ -73,6 +73,164 @@ } } } + }, + "/reserves": { + "get": { + "operationId": "listReserves", + "tags": ["borrowers", "reserves"], + "parameters": [ + { + "name": "borrowernumber", + "in": "query", + "description": "Internal borrower identifier", + "required": true, + "type": "integer" + } + ], + "produces": ["application/json"], + "responses": { + "200": { + "description": "A list of reserves", + "schema": { "$ref": "#/definitions/reserves" } + }, + "404": { + "description": "Borrower not found", + "schema": { "$ref": "#/definitions/error" } + } + } + }, + "post": { + "operationId": "addReserve", + "tags": ["borrowers", "reserves"], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "A JSON object containing informations about the new reserve", + "required": true, + "schema": { + "type": "object", + "properties": { + "borrowernumber": { + "description": "Borrower internal identifier", + "type": "integer" + }, + "biblionumber": { + "description": "Biblio internal identifier", + "type": "integer" + }, + "itemnumber": { + "description": "Item internal identifier", + "type": "integer" + }, + "branchcode": { + "description": "Pickup location", + "type": "string" + }, + "expirationdate": { + "description": "Reserve end date", + "type": "string", + "format": "date" + } + } + } + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "201": { + "description": "Created reserve", + "schema": { "$ref": "#/definitions/reserve" } + }, + "400": { + "description": "Missing or wrong parameters", + "schema": { "$ref": "#/definitions/error" } + }, + "403": { + "description": "Reserve not allowed", + "schema": { "$ref": "#/definitions/error" } + }, + "404": { + "description": "Borrower not found", + "schema": { "$ref": "#/definitions/error" } + }, + "500": { + "description": "Internal error", + "schema": { "$ref": "#/definitions/error" } + } + } + } + }, + "/reserves/{reserve_id}": { + "put": { + "operationId": "editReserve", + "tags": ["reserves"], + "parameters": [ + { "$ref": "#/parameters/reserveIdPathParam" }, + { + "name": "body", + "in": "body", + "description": "A JSON object containing fields to modify", + "required": true, + "schema": { + "type": "object", + "properties": { + "priority": { + "description": "Position in waiting queue", + "type": "integer", + "minimum": 1 + }, + "branchcode": { + "description": "Pickup location", + "type": "string" + }, + "suspend_until": { + "description": "Suspend until", + "type": "string", + "format": "date" + } + } + } + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "Updated reserve", + "schema": { "$ref": "#/definitions/reserve" } + }, + "400": { + "description": "Missing or wrong parameters", + "schema": { "$ref": "#/definitions/error" } + }, + "404": { + "description": "Reserve not found", + "schema": { "$ref": "#/definitions/error" } + } + } + }, + "delete": { + "operationId": "deleteReserve", + "tags": ["reserves"], + "parameters": [ + { "$ref": "#/parameters/reserveIdPathParam" } + ], + "produces": ["application/json"], + "responses": { + "200": { + "description": "Successful deletion", + "schema": { + "type": "object" + } + }, + "404": { + "description": "Reserve not found", + "schema": { "$ref": "#/definitions/error" } + } + } + } } }, "definitions": { @@ -85,6 +243,13 @@ "description": "Internal patron identifier", "required": true, "type": "integer" + }, + "reserveIdPathParam": { + "name": "reserve_id", + "in": "path", + "description": "Internal reserve identifier", + "required": true, + "type": "integer" } } } diff --git a/t/db_dependent/api/v1/reserves.t b/t/db_dependent/api/v1/reserves.t new file mode 100644 index 0000000..eeccbfd --- /dev/null +++ b/t/db_dependent/api/v1/reserves.t @@ -0,0 +1,154 @@ +#!/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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Test::More tests => 30; +use Test::Mojo; + +use DateTime; + +use C4::Context; +use C4::Biblio; +use C4::Items; +use C4::Reserves; + +use Koha::Database; +use Koha::Patron; + +my $dbh = C4::Context->dbh; +$dbh->{AutoCommit} = 0; +$dbh->{RaiseError} = 1; + +my $t = Test::Mojo->new('Koha::REST::V1'); + +my $categorycode = Koha::Database->new()->schema()->resultset('Category')->first()->categorycode(); +my $branchcode = Koha::Database->new()->schema()->resultset('Branch')->first()->branchcode(); + +my $borrower = Koha::Patron->new; +$borrower->categorycode( $categorycode ); +$borrower->branchcode( $branchcode ); +$borrower->surname("Test Surname"); +$borrower->store; +my $borrowernumber = $borrower->borrowernumber; + +my $borrower2 = Koha::Patron->new; +$borrower2->categorycode( $categorycode ); +$borrower2->branchcode( $branchcode ); +$borrower2->surname("Test Surname 2"); +$borrower2->store; +my $borrowernumber2 = $borrower2->borrowernumber; + +my $biblionumber = create_biblio('RESTful Web APIs'); +my $itemnumber = create_item($biblionumber, 'TEST000001'); + +my $reserve_id = C4::Reserves::AddReserve($branchcode, $borrowernumber, + $biblionumber, undef, 1, undef, undef, undef, '', $itemnumber); + +# Add another reserve to be able to change first reserve's rank +C4::Reserves::AddReserve($branchcode, $borrowernumber2, + $biblionumber, undef, 2, undef, undef, undef, '', $itemnumber); + +my $suspend_until = DateTime->now->add(days => 10)->ymd; +my $put_data = { + priority => 2, + suspend_until => $suspend_until, +}; +$t->put_ok("/api/v1/reserves/$reserve_id" => json => $put_data) + ->status_is(200) + ->json_is('/reserve_id', $reserve_id) + ->json_is('/suspend_until', $suspend_until . ' 00:00:00') + ->json_is('/priority', 2); + +$t->delete_ok("/api/v1/reserves/$reserve_id") + ->status_is(200); + +$t->put_ok("/api/v1/reserves/$reserve_id" => json => $put_data) + ->status_is(404) + ->json_has('/error'); + +$t->delete_ok("/api/v1/reserves/$reserve_id") + ->status_is(404) + ->json_has('/error'); + + +$t->get_ok("/api/v1/reserves?borrowernumber=$borrowernumber") + ->status_is(200) + ->json_is([]); + +my $inexisting_borrowernumber = $borrowernumber2 + 1; +$t->get_ok("/api/v1/reserves?borrowernumber=$inexisting_borrowernumber") + ->status_is(404) + ->json_has('/error'); + +$dbh->do('DELETE FROM issuingrules'); +$dbh->do(q{ + INSERT INTO issuingrules (categorycode, branchcode, itemtype, reservesallowed) + VALUES (?, ?, ?, ?) +}, {}, '*', '*', '*', 1); + +my $expirationdate = DateTime->now->add(days => 10)->ymd; +my $post_data = { + borrowernumber => int($borrowernumber), + biblionumber => int($biblionumber), + itemnumber => int($itemnumber), + branchcode => $branchcode, + expirationdate => $expirationdate, +}; +$t->post_ok("/api/v1/reserves" => json => $post_data) + ->status_is(201) + ->json_has('/reserve_id'); + +$reserve_id = $t->tx->res->json->{reserve_id}; + +$t->get_ok("/api/v1/reserves?borrowernumber=$borrowernumber") + ->status_is(200) + ->json_is('/0/reserve_id', $reserve_id) + ->json_is('/0/expirationdate', $expirationdate) + ->json_is('/0/branchcode', $branchcode); + +$t->post_ok("/api/v1/reserves" => json => $post_data) + ->status_is(403) + ->json_like('/error', qr/tooManyReserves/); + + +$dbh->rollback; + +sub create_biblio { + my ($title) = @_; + + my $record = new MARC::Record; + $record->append_fields( + new MARC::Field('200', ' ', ' ', a => $title), + ); + + my ($biblionumber) = C4::Biblio::AddBiblio($record, ''); + + return $biblionumber; +} + +sub create_item { + my ($biblionumber, $barcode) = @_; + + my $item = { + barcode => $barcode, + }; + + my $itemnumber = C4::Items::AddItem($item, $biblionumber); + + return $itemnumber; +} -- 2.8.0.rc3