From d65767af08c06490573425de6db74b8b8ed6e15a Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Mon, 27 Jun 2016 16:03:49 +0300 Subject: [PATCH] Bug 16825: Add API route for getting an item GET /api/v1/items/{itemnumber} Gets one Item This patch adds route to get one item from koha.items table. This table has a column "itemnotes_nonpublic", which should not be returned if the user has no catalogue permission. The OpacHiddenItems syspref restriction is considered for users who are not staff. They see "Item Not Found" when trying to view item hidden from OPAC. Only staff can see those items. To test: 1. Apply patch 2. Open a browser tab on Koha staff and log in (to create CGISESSID cookie). 3. Send GET request to http://yourlibrary/api/v1/items/YYY where YYY is an existing itemnumber. 4. Make sure the returned data is correct. 5. Run unit tests in t/db_dependent/api/v1/items.t Sponsored-by: Koha-Suomi Oy Signed-off-by: Josef Moravec Signed-off-by: Johanna Raisa --- Koha/REST/V1/Items.pm | 236 +++++++++++++++++++++++++++ api/v1/swagger/definitions.json | 3 + api/v1/swagger/definitions/item.json | 192 ++++++++++++++++++++++ api/v1/swagger/parameters.json | 3 + api/v1/swagger/parameters/item.json | 9 + api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/items.json | 47 ++++++ api/v1/swagger/x-primitives.json | 8 + t/db_dependent/api/v1/items.t | 151 +++++++++++++++++ 9 files changed, 652 insertions(+) create mode 100644 Koha/REST/V1/Items.pm create mode 100644 api/v1/swagger/definitions/item.json create mode 100644 api/v1/swagger/parameters/item.json create mode 100644 api/v1/swagger/paths/items.json create mode 100644 t/db_dependent/api/v1/items.t diff --git a/Koha/REST/V1/Items.pm b/Koha/REST/V1/Items.pm new file mode 100644 index 0000000000..46af9f9118 --- /dev/null +++ b/Koha/REST/V1/Items.pm @@ -0,0 +1,236 @@ +package Koha::REST::V1::Items; + +# 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 Mojo::JSON; + +use C4::Auth qw( haspermission ); +use C4::Items qw( GetHiddenItemnumbers ); + +use Koha::Items; + +use Try::Tiny; + +sub get { + my $c = shift->openapi->valid_input or return; + + my $item; + try { + $item = Koha::Items->find($c->validation->param('item_id')); + + # Hide non-public itemnotes if user has no staff access + my $user = $c->stash('koha.user'); + unless ($user && haspermission($user->userid, 'catalogue')) { + push my @items, $item->unblessed; + my @hiddenitems = C4::Items::GetHiddenItemnumbers( ({items => (\@items)}) ); + my %hiddenitems = map { $_ => 1 } @hiddenitems; + + # Pretend it was not found as it's hidden from OPAC to regular users + if ($hiddenitems{$item->itemnumber}) { + return $c->render( status => 404, + openapi => { error => 'Item not found'} ) ; + } + + $item->set({ itemnotes_nonpublic => undef }); + } + + return $c->render( status => 200, openapi => _to_api( $item->TO_JSON ) ); + } + catch { + unless ( defined $item ) { + return $c->render( status => 404, + openapi => { error => 'Item not found'} ); + } + if ( $_->isa('DBIx::Class::Exception') ) { + return $c->render( status => 500, + openapi => { error => $_->{msg} } ); + } + else { + return $c->render( status => 500, + openapi => { error => "Something went wrong, check the logs."} ); + } + }; +} + +=head3 _to_api + +Helper function that maps unblessed Koha::Hold objects into REST api +attribute names. + +=cut + +sub _to_api { + my $item = shift; + + # Rename attributes + foreach my $column ( keys %{ $Koha::REST::V1::Items::to_api_mapping } ) { + my $mapped_column = $Koha::REST::V1::Items::to_api_mapping->{$column}; + if ( exists $item->{ $column } + && defined $mapped_column ) + { + # key != undef + $item->{ $mapped_column } = delete $item->{ $column }; + } + elsif ( exists $item->{ $column } + && !defined $mapped_column ) + { + # key == undef + delete $item->{ $column }; + } + } + + return $item; +} + +=head3 _to_model + +Helper function that maps REST api objects into Koha::Hold +attribute names. + +=cut + +sub _to_model { + my $item = shift; + + foreach my $attribute ( keys %{ $Koha::REST::V1::Items::to_model_mapping } ) { + my $mapped_attribute = $Koha::REST::V1::Items::to_model_mapping->{$attribute}; + if ( exists $item->{ $attribute } + && defined $mapped_attribute ) + { + # key => !undef + $item->{ $mapped_attribute } = delete $item->{ $attribute }; + } + elsif ( exists $item->{ $attribute } + && !defined $mapped_attribute ) + { + # key => undef / to be deleted + delete $item->{ $attribute }; + } + } + + return $item; +} + +=head2 Global variables + +=head3 $to_api_mapping + +=cut + +our $to_api_mapping = { + itemnumber => 'item_id', + biblionumber => 'biblio_id', + biblioitemnumber => 'biblioitem_id', + barcode => 'barcode', + dateaccessioned => 'dateaccessioned', + booksellerid => 'booksellerid', + homebranch => 'homebranch', + price => 'price', + replacementprice => 'replacementprice', + replacementpricedate => 'replacementpricedate', + datelastborrowed => 'datelastborrowed', + datelastseen => 'datelastseen', + stack => 'stack', + notforloan => 'notforloan', + damaged => 'damaged', + damaged_on => 'damaged_on', + itemlost => 'itemlost', + itemlost_on => 'itemlost_on', + withdrawn => 'withdrawn', + withdrawn_on => 'withdrawn_on', + itemcallnumber => 'itemcallnumber', + coded_location_qualifier => 'coded_location_qualifier', + issues => 'issues', + renewals => 'renewals', + reserves => 'reserves', + restricted => 'restricted', + itemnotes => 'itemnotes', + itemnotes_nonpublic => 'itemnotes_nonpublic', + holdingbranch => 'holdingbranch', + paidfor => 'paidfor', + timestamp => 'timestamp', + location => 'location', + permanent_location => 'permanent_location', + onloan => 'onloan', + cn_source => 'cn_source', + cn_sort => 'cn_sort', + ccode => 'ccode', + materials => 'materials', + uri => 'uri', + itype => 'itype', + more_subfields_xml => 'more_subfields_xml', + enumchron => 'enumchron', + copynumber => 'copynumber', + stocknumber => 'stocknumber', + new_status => 'new_status' +}; + +=head3 $to_model_mapping + +=cut + +our $to_model_mapping = { + item_id => 'itemnumber', + biblio_id => 'biblionumber', + biblioitem_id => 'biblioitemnumber', + barcode => 'barcode', + dateaccessioned => 'dateaccessioned', + booksellerid => 'booksellerid', + homebranch => 'homebranch', + price => 'price', + replacementprice => 'replacementprice', + replacementpricedate => 'replacementpricedate', + datelastborrowed => 'datelastborrowed', + datelastseen => 'datelastseen', + stack => 'stack', + notforloan => 'notforloan', + damaged => 'damaged', + damaged_on => 'damaged_on', + itemlost => 'itemlost', + itemlost_on => 'itemlost_on', + withdrawn => 'withdrawn', + withdrawn_on => 'withdrawn_on', + itemcallnumber => 'itemcallnumber', + coded_location_qualifier => 'coded_location_qualifier', + issues => 'issues', + renewals => 'renewals', + reserves => 'reserves', + restricted => 'restricted', + itemnotes => 'itemnotes', + itemnotes_nonpublic => 'itemnotes_nonpublic', + holdingbranch => 'holdingbranch', + paidfor => 'paidfor', + timestamp => 'timestamp', + location => 'location', + permanent_location => 'permanent_location', + onloan => 'onloan', + cn_source => 'cn_source', + cn_sort => 'cn_sort', + ccode => 'ccode', + materials => 'materials', + uri => 'uri', + itype => 'itype', + more_subfields_xml => 'more_subfields_xml', + enumchron => 'enumchron', + copynumber => 'copynumber', + stocknumber => 'stocknumber', + new_status => 'new_status' +}; + +1; diff --git a/api/v1/swagger/definitions.json b/api/v1/swagger/definitions.json index 1dc90f3f58..8cc0bd4d2c 100644 --- a/api/v1/swagger/definitions.json +++ b/api/v1/swagger/definitions.json @@ -23,6 +23,9 @@ "library": { "$ref": "definitions/library.json" }, + "item": { + "$ref": "definitions/item.json" + }, "patron": { "$ref": "definitions/patron.json" }, diff --git a/api/v1/swagger/definitions/item.json b/api/v1/swagger/definitions/item.json new file mode 100644 index 0000000000..20a36f4b3f --- /dev/null +++ b/api/v1/swagger/definitions/item.json @@ -0,0 +1,192 @@ +{ + "type": "object", + "properties": { + "item_id": { + "$ref": "../x-primitives.json#/item_id" + }, + "biblio_id": { + "$ref": "../x-primitives.json#/biblio_id" + }, + "biblioitem_id": { + "$ref": "../x-primitives.json#/biblioitem_id" + }, + "barcode": { + "type": ["string", "null"], + "description": "item barcode" + }, + "dateaccessioned": { + "type": ["string", "null"], + "format": "date", + "description": "date the item was acquired or added to Koha" + }, + "booksellerid": { + "type": ["string", "null"], + "description": "where the item was purchased" + }, + "homebranch": { + "type": ["string", "null"], + "description": "library that owns this item" + }, + "price": { + "type": ["number", "null"], + "description": "purchase price" + }, + "replacementprice": { + "type": ["number", "null"], + "description": "cost the library charges to replace the item if it has been marked lost" + }, + "replacementpricedate": { + "type": ["string", "null"], + "format": "date", + "description": "the date the price is effective from" + }, + "datelastborrowed": { + "type": ["string", "null"], + "format": "date", + "description": "the date the item was last checked out/issued" + }, + "datelastseen": { + "type": ["string", "null"], + "format": "date", + "description": "the date the item was last see (usually the last time the barcode was scanned or inventory was done)" + }, + "stack": { + "type": ["integer", "null"], + "description": "?" + }, + "notforloan": { + "type": "integer", + "description": "authorized value defining why this item is not for loan" + }, + "damaged": { + "type": "integer", + "description": "authorized value defining this item as damaged" + }, + "damaged_on": { + "type": ["string", "null"], + "description": "authorized value defining this item as damaged" + }, + "itemlost": { + "type": "integer", + "description": "authorized value defining this item as lost" + }, + "itemlost_on": { + "type": ["string", "null"], + "format": "date-time", + "description": "the date and time an item was last marked as lost, NULL if not lost" + }, + "withdrawn": { + "type": "integer", + "description": "authorized value defining this item as withdrawn" + }, + "withdrawn_on": { + "type": ["string", "null"], + "format": "date-time", + "description": "the date and time an item was last marked as withdrawn, NULL if not withdrawn" + }, + "itemcallnumber": { + "type": ["string", "null"], + "description": "call number for this item" + }, + "coded_location_qualifier": { + "type": ["string", "null"], + "description": "coded location qualifier" + }, + "issues": { + "type": ["integer", "null"], + "description": "number of times this item has been checked out/issued" + }, + "renewals": { + "type": ["integer", "null"], + "description": "number of times this item has been renewed" + }, + "reserves": { + "type": ["integer", "null"], + "description": "number of times this item has been placed on hold/reserved" + }, + "restricted": { + "type": ["integer", "null"], + "description": "authorized value defining use restrictions for this item" + }, + "itemnotes": { + "type": ["string", "null"], + "description": "public notes on this item" + }, + "itemnotes_nonpublic": { + "type": ["string", "null"], + "description": "non-public notes on this item" + }, + "holdingbranch": { + "type": ["string", "null"], + "description": "library that is currently in possession item" + }, + "paidfor": { + "type": ["string", "null"], + "description": "?" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "date and time this item was last altered" + }, + "location": { + "type": ["string", "null"], + "description": "authorized value for the shelving location for this item" + }, + "permanent_location": { + "type": ["string", "null"], + "description": "linked to the CART and PROC temporary locations feature, stores the permanent shelving location" + }, + "onloan": { + "type": ["string", "null"], + "format": "date", + "description": "defines if item is checked out (NULL for not checked out, and checkout date for checked out)" + }, + "cn_source": { + "type": ["string", "null"], + "description": "classification source used on this item" + }, + "cn_sort": { + "type": ["string", "null"], + "description": "?" + }, + "ccode": { + "type": ["string", "null"], + "description": "authorized value for the collection code associated with this item" + }, + "materials": { + "type": ["string", "null"], + "description": "materials specified" + }, + "uri": { + "type": ["string", "null"], + "description": "URL for the item" + }, + "itype": { + "type": ["string", "null"], + "description": "itemtype defining the type for this item" + }, + "more_subfields_xml": { + "type": ["string", "null"], + "description": "additional 952 subfields in XML format" + }, + "enumchron": { + "type": ["string", "null"], + "description": "serial enumeration/chronology for the item" + }, + "copynumber": { + "type": ["string", "null"], + "description": "copy number" + }, + "stocknumber": { + "type": ["string", "null"], + "description": "inventory number" + }, + "new_status": { + "type": ["string", "null"], + "description": "'new' value, whatever free-text information." + } + }, + "additionalProperties": false, + "required": ["item_id", "biblio_id", "biblioitem_id", "notforloan", "damaged", "itemlost", "withdrawn"] +} diff --git a/api/v1/swagger/parameters.json b/api/v1/swagger/parameters.json index fce13be958..b5aa33ecbb 100644 --- a/api/v1/swagger/parameters.json +++ b/api/v1/swagger/parameters.json @@ -17,6 +17,9 @@ "library_id_pp": { "$ref": "parameters/library.json#/library_id_pp" }, + "item_id_pp": { + "$ref": "parameters/item.json#/item_id_pp" + }, "vendoridPathParam": { "$ref": "parameters/vendor.json#/vendoridPathParam" }, diff --git a/api/v1/swagger/parameters/item.json b/api/v1/swagger/parameters/item.json new file mode 100644 index 0000000000..8e54e9de37 --- /dev/null +++ b/api/v1/swagger/parameters/item.json @@ -0,0 +1,9 @@ +{ + "item_id_pp": { + "name": "item_id", + "in": "path", + "description": "Internal item identifier", + "required": true, + "type": "integer" + } +} diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index 33cfd3aa5a..c08d4d83d3 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -44,6 +44,9 @@ "/libraries/{library_id}": { "$ref": "paths/libraries.json#/~1libraries~1{library_id}" }, + "/items/{item_id}": { + "$ref": "paths/items.json#/~1items~1{item_id}" + }, "/patrons": { "$ref": "paths/patrons.json#/~1patrons" }, diff --git a/api/v1/swagger/paths/items.json b/api/v1/swagger/paths/items.json new file mode 100644 index 0000000000..a1949ce72a --- /dev/null +++ b/api/v1/swagger/paths/items.json @@ -0,0 +1,47 @@ +{ + "/items/{item_id}": { + "get": { + "x-mojo-to": "Items#get", + "operationId": "getItem", + "tags": ["items"], + "parameters": [{ + "$ref": "../parameters.json#/item_id_pp" + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "An item", + "schema": { + "$ref": "../definitions.json#/item" + } + }, + "400": { + "description": "Missing or wrong parameters", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "Item 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" + } + } + } + } + } +} diff --git a/api/v1/swagger/x-primitives.json b/api/v1/swagger/x-primitives.json index ee15fa6aa1..986e33a7c5 100644 --- a/api/v1/swagger/x-primitives.json +++ b/api/v1/swagger/x-primitives.json @@ -3,6 +3,10 @@ "type": "integer", "description": "Internal biblio identifier" }, + "biblioitem_id": { + "type": "integer", + "description": "Internal biblioitem identifier" + }, "patron_id": { "type": "integer", "description": "Internal patron identifier" @@ -30,6 +34,10 @@ "type": ["string", "null"], "description": "patron's first name" }, + "item_id": { + "type": "integer", + "description": "internally assigned item identifier" + }, "phone": { "type": ["string", "null"], "description": "primary phone number for patron's primary address" diff --git a/t/db_dependent/api/v1/items.t b/t/db_dependent/api/v1/items.t new file mode 100644 index 0000000000..2f89e963db --- /dev/null +++ b/t/db_dependent/api/v1/items.t @@ -0,0 +1,151 @@ +#!/usr/bin/env perl + +# Copyright 2016 Koha-Suomi +# +# 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 => 4; +use Test::Mojo; +use Test::Warn; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use C4::Auth; +use Koha::Items; +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new(); + +# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling +# this affects the other REST api tests +t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + +my $remote_address = '127.0.0.1'; +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'list() tests' => sub { + plan tests => 2; + + # Not yet implemented + my $tx = $t->ua->build_tx(GET => "/api/v1/items"); + $t->request_ok($tx) + ->status_is(404); +}; + +subtest 'get() tests' => sub { + plan tests => 8; + + $schema->storage->txn_begin; + + my $biblio = $builder->build({ + source => 'Biblio' + }); + my $biblionumber = $biblio->{biblionumber}; + my $item = $builder->build({ + source => 'Item', + value => { + biblionumber => $biblionumber, + withdrawn => 1, # tested with OpacHiddenItems + } + }); + my $itemnumber = $item->{itemnumber}; + + my ($patron, $sessionid) = create_user_and_session({ authorized=>0 }); + my ($librarian, $lib_sessionid) = create_user_and_session({ authorized=>4 }); + + my $nonExistentItemnumber = -14362719; + my $tx = $t->ua->build_tx(GET => "/api/v1/items/$nonExistentItemnumber"); + $tx->req->cookies({name => 'CGISESSID', value => $sessionid}); + $t->request_ok($tx) + ->status_is(404); + + subtest 'Confirm effectiveness of OpacHiddenItems' => sub { + plan tests => 7; + + t::lib::Mocks::mock_preference('OpacHiddenItems', ''); + $tx = $t->ua->build_tx(GET => "/api/v1/items/$itemnumber"); + $tx->req->cookies({name => 'CGISESSID', value => $sessionid}); + $t->request_ok($tx) + ->status_is(200) + ->json_is('/item_id' => $itemnumber) + ->json_is('/biblio_id' => $biblionumber) + ->json_is('/itemnotes_nonpublic' => undef); + + t::lib::Mocks::mock_preference('OpacHiddenItems', 'withdrawn: [1]'); + $tx = $t->ua->build_tx(GET => "/api/v1/items/$itemnumber"); + $tx->req->cookies({name => 'CGISESSID', value => $sessionid}); + $t->request_ok($tx) + ->status_is(404); + }; + + $tx = $t->ua->build_tx(GET => "/api/v1/items/$itemnumber"); + $tx->req->cookies({name => 'CGISESSID', value => $lib_sessionid}); + $t->request_ok($tx) + ->status_is(200) + ->json_is('/item_id' => $itemnumber) + ->json_is('/biblio_id' => $biblionumber) + ->json_is('/itemnotes_nonpublic' => $item->{itemnotes_nonpublic}); + + $schema->storage->txn_rollback; +}; + +subtest 'update() tests' => sub { + plan tests => 2; + + # Not yet implemented + my $tx = $t->ua->build_tx(PUT => "/api/v1/items/1"); + $t->request_ok($tx) + ->status_is(404); +}; + +subtest 'delete() tests' => sub { + plan tests => 2; + + # Not yet implemented + my $tx = $t->ua->build_tx(DELETE => "/api/v1/items/1"); + $t->request_ok($tx) + ->status_is(404); +}; + +sub create_user_and_session { + + my $args = shift; + my $flags = ( $args->{authorized} ) ? $args->{authorized} : 0; + my $dbh = C4::Context->dbh; + + my $user = $builder->build_object( + { + class => 'Koha::Patrons', + value => { + flags => $flags + } + } + ); + + # Create a session for the authorized user + my $session = C4::Auth::get_session(''); + $session->param( 'number', $user->borrowernumber ); + $session->param( 'id', $user->userid ); + $session->param( 'ip', '127.0.0.1' ); + $session->param( 'lasttime', time() ); + $session->flush; + + return ( $user->{borrowernumber}, $session->id ); +} -- 2.17.1