Koha-clms/Koha/REST/V1/Biblios.pm

933 lines
28 KiB
Perl

package Koha::REST::V1::Biblios;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Mojo::Base 'Mojolicious::Controller';
use Koha::Biblios;
use Koha::DateUtils;
use Koha::Ratings;
use Koha::RecordSources;
use C4::Biblio qw( DelBiblio AddBiblio ModBiblio );
use C4::Search qw( FindDuplicate );
use C4::Auth qw( haspermission );
use C4::Barcodes::ValueBuilder;
use C4::Context;
use Koha::Items;
use List::MoreUtils qw( any );
use MARC::Record::MiJ;
use Try::Tiny qw( catch try );
use JSON qw( decode_json );
=head1 API
=head2 Methods
=head3 get
Controller function that handles retrieving a single biblio object
=cut
sub get {
my $c = shift->openapi->valid_input or return;
my $attributes;
$attributes = { prefetch => ['metadata'] } # don't prefetch metadata if not needed
unless $c->req->headers->accept =~ m/application\/json/;
my $biblio = Koha::Biblios->find( { biblionumber => $c->param('biblio_id') }, $attributes );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
if ( $c->req->headers->accept =~ m/application\/json/ ) {
return $c->render(
status => 200,
json => $c->objects->to_api($biblio),
);
} else {
my $metadata = $biblio->metadata;
my $record = $metadata->record;
my $schema = $metadata->schema // C4::Context->preference("marcflavour");
$c->respond_to(
marcxml => {
status => 200,
format => 'marcxml',
text => $record->as_xml_record($schema),
},
mij => {
status => 200,
format => 'mij',
data => $record->to_mij
},
marc => {
status => 200,
format => 'marc',
text => $record->as_usmarc
},
txt => {
status => 200,
format => 'text/plain',
text => $record->as_formatted
},
any => {
status => 406,
openapi => [
"application/json",
"application/marcxml+xml",
"application/marc-in-json",
"application/marc",
"text/plain"
]
}
);
}
} catch {
$c->unhandled_exception($_);
};
}
=head3 delete
Controller function that handles deleting a biblio object
=cut
sub delete {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( $c->param('biblio_id') );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
my $error = DelBiblio( $biblio->id );
if ($error) {
return $c->render(
status => 409,
openapi => { error => $error }
);
} else {
return $c->render_resource_deleted;
}
} catch {
$c->unhandled_exception($_);
};
}
=head3 get_public
Controller function that handles retrieving a single biblio object
=cut
sub get_public {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find(
{ biblionumber => $c->param('biblio_id') },
{ prefetch => ['metadata'] }
);
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
my $schema = $biblio->metadata->schema // C4::Context->preference("marcflavour");
my $patron = $c->stash('koha.user');
# Check if the bibliographic record is suppressed in OPAC
if ( C4::Context->preference('OpacSuppression') && $biblio->opac_suppressed ) {
return $c->render_resource_not_found("Bibliographic record");
}
# Check if the bibliographic record should be hidden for unprivileged access
# unless there's a logged in user, and there's an exception for it's category
my $opachiddenitems_rules = C4::Context->yaml_preference('OpacHiddenItems');
unless ( $patron and $patron->category->override_hidden_items ) {
if ( $biblio->hidden_in_opac( { rules => $opachiddenitems_rules } ) ) {
return $c->render_resource_not_found("Bibliographic record");
}
}
my $record = $biblio->metadata_record( { interface => 'opac', patron => $patron } );
$c->respond_to(
marcxml => {
status => 200,
format => 'marcxml',
text => $record->as_xml_record($schema),
},
mij => {
status => 200,
format => 'mij',
data => $record->to_mij
},
marc => {
status => 200,
format => 'marc',
text => $record->as_usmarc
},
txt => {
status => 200,
format => 'text/plain',
text => $record->as_formatted
},
any => {
status => 406,
openapi => [
"application/marcxml+xml",
"application/marc-in-json",
"application/marc",
"text/plain"
]
}
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 get_bookings
Controller function that handles retrieving biblio's bookings
=cut
sub get_bookings {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( { biblionumber => $c->param('biblio_id') }, { prefetch => ['bookings'] } );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
my $bookings_rs = $biblio->bookings;
my $bookings = $c->objects->search($bookings_rs);
return $c->render(
status => 200,
openapi => $bookings
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 get_items
Controller function that handles retrieving biblio's items
=cut
sub get_items {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( { biblionumber => $c->param('biblio_id') }, { prefetch => ['items'] } );
my $bookable_only = $c->param('bookable');
# Deletion of parameter to avoid filtering on the items table in the case of bookings on 'itemtype'
$c->req->params->remove('bookable');
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
# FIXME We need to order_by serial.publisheddate if we have _order_by=+me.serial_issue_number
# FIXME Do we always need host_items => 1 or depending on a flag?
# FIXME Should we prefetch => ['issue','branchtransfer']?
my $items_rs = $biblio->items( { host_items => 1 } )->search_ordered( {}, { join => 'biblioitem' } );
$items_rs = $items_rs->filter_by_bookable if $bookable_only;
# FIXME We need to order_by serial.publisheddate if we have _order_by=+me.serial_issue_number
my $items = $c->objects->search($items_rs);
return $c->render(
status => 200,
openapi => $items
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 add_item
Controller function that handles creating a biblio's item
=cut
sub add_item {
my $c = shift->openapi->valid_input or return;
try {
my $biblio_id = $c->param('biblio_id');
my $biblio = Koha::Biblios->find($biblio_id);
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
my $body = $c->req->json;
$body->{biblio_id} = $biblio_id;
# Don't save extended subfields yet. To be done in another bug.
$body->{extended_subfields} = undef;
my $item = Koha::Item->new_from_api($body);
if ( !defined $item->barcode ) {
# FIXME This should be moved to Koha::Item->store
my $autoBarcode = C4::Context->preference('autoBarcode');
my $barcode = '';
if ( !$autoBarcode || $autoBarcode eq 'OFF' ) {
#We do nothing
} elsif ( $autoBarcode eq 'incremental' ) {
($barcode) = C4::Barcodes::ValueBuilder::incremental::get_barcode;
} elsif ( $autoBarcode eq 'annual' ) {
my $year = Koha::DateUtils::dt_from_string()->year();
($barcode) = C4::Barcodes::ValueBuilder::annual::get_barcode( { year => $year } );
} elsif ( $autoBarcode eq 'hbyymmincr' ) {
# Generates a barcode where
# hb = home branch Code,
# yymm = year/month catalogued,
# incr = incremental number,
# reset yearly -fbcit
my $now = Koha::DateUtils::dt_from_string();
my $year = $now->year();
my $month = $now->month();
my $homebranch = $item->homebranch // '';
($barcode) = C4::Barcodes::ValueBuilder::hbyymmincr::get_barcode( { year => $year, mon => $month } );
$barcode = $homebranch . $barcode;
} elsif ( $autoBarcode eq 'EAN13' ) {
($barcode) = C4::Barcodes::ValueBuilder::EAN13::get_barcode();
} else {
warn "ERROR: unknown autoBarcode: $autoBarcode";
}
$item->barcode($barcode) if $barcode;
}
$item->store->discard_changes;
my $base_url = $c->req->url->to_string;
$base_url =~ s|/biblios/\d+||;
$c->res->headers->location( $base_url . '/' . $item->id );
$c->render(
status => 201,
openapi => $c->objects->to_api($item),
);
} catch {
if ( blessed $_ and $_->isa('Koha::Exceptions::Object::DuplicateID') ) {
return $c->render(
status => 409,
openapi => { error => 'Duplicate barcode.' }
);
}
$c->unhandled_exception($_);
}
}
=head3 update_item
Controller function that handles updating a biblio's item
=cut
sub update_item {
my $c = shift->openapi->valid_input or return;
try {
my $biblio_id = $c->param('biblio_id');
my $item_id = $c->param('item_id');
my $biblio = Koha::Biblios->find( { biblionumber => $biblio_id } );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
my $item = $biblio->items->find( { itemnumber => $item_id } );
return $c->render_resource_not_found("Item")
unless $item;
my $body = $c->req->json;
$body->{biblio_id} = $biblio_id;
# Don't save extended subfields yet. To be done in another bug.
$body->{extended_subfields} = undef;
$item->set_from_api($body);
$item->store->discard_changes;
$c->render(
status => 200,
openapi => $c->objects->to_api($item),
);
} catch {
if ( blessed $_ and $_->isa('Koha::Exceptions::Object::DuplicateID') ) {
return $c->render(
status => 409,
openapi => { error => 'Duplicate barcode.' }
);
}
$c->unhandled_exception($_);
}
}
=head3 get_checkouts
List Koha::Checkout objects
=cut
sub get_checkouts {
my $c = shift->openapi->valid_input or return;
my $checked_in = $c->param('checked_in');
$c->req->params->remove('checked_in');
try {
my $biblio = Koha::Biblios->find( $c->param('biblio_id') );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
my $checkouts =
($checked_in)
? $c->objects->search( $biblio->old_checkouts )
: $c->objects->search( $biblio->current_checkouts );
return $c->render(
status => 200,
openapi => $checkouts
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 pickup_locations
Method that returns the possible pickup_locations for a given biblio
used for building the dropdown selector
=cut
sub pickup_locations {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( $c->param('biblio_id') );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
my $patron = Koha::Patrons->find( $c->param('patron_id') );
$c->req->params->remove('patron_id');
unless ($patron) {
return $c->render(
status => 400,
openapi => { error => "Patron not found" }
);
}
return try {
my $pl_set = $biblio->pickup_locations( { patron => $patron } );
my @response = ();
if ( C4::Context->preference('AllowHoldPolicyOverride') ) {
my $libraries_rs = Koha::Libraries->search( { pickup_location => 1 } );
my $libraries = $c->objects->search($libraries_rs);
@response = map {
my $library = $_;
$library->{needs_override} =
( any { $_->branchcode eq $library->{library_id} } @{ $pl_set->as_list } )
? Mojo::JSON->false
: Mojo::JSON->true;
$library;
} @{$libraries};
} else {
my $pickup_locations = $c->objects->search($pl_set);
@response = map { $_->{needs_override} = Mojo::JSON->false; $_; } @{$pickup_locations};
}
@response = map {
if ( exists $pl_set->{_pickup_location_items}->{ $_->{library_id} }
&& ref $pl_set->{_pickup_location_items}->{ $_->{library_id} } eq 'ARRAY' )
{
$_->{pickup_items} = $pl_set->{_pickup_location_items}->{ $_->{library_id} };
} else {
$_->{pickup_items} = [];
}
$_;
} @response;
return $c->render(
status => 200,
openapi => \@response
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 get_items_public
Controller function that handles retrieving biblio's items, for unprivileged
access.
=cut
sub get_items_public {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find(
$c->param('biblio_id'),
{ prefetch => ['items'] }
);
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return try {
my $patron = $c->stash('koha.user');
my $items_rs = $biblio->items->filter_by_visible_in_opac( { patron => $patron } );
my $items = $c->objects->search($items_rs);
return $c->render(
status => 200,
openapi => $items
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 set_rating
Set rating for the logged in user
=cut
sub set_rating {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( $c->param('biblio_id') );
$c->render_resource_not_found("Bibliographic record")
unless $biblio;
my $patron = $c->stash('koha.user');
unless ($patron) {
return $c->render(
status => 403,
openapi => { error => "Cannot rate. Reason: must be logged-in" }
);
}
my $body = $c->req->json;
my $rating_value = $body->{rating};
return try {
my $rating = Koha::Ratings->find(
{
biblionumber => $biblio->biblionumber,
borrowernumber => $patron->borrowernumber,
}
);
$rating->delete if $rating;
if ($rating_value) { # Cannot set to 0 from the UI
$rating = Koha::Rating->new(
{
biblionumber => $biblio->biblionumber,
borrowernumber => $patron->borrowernumber,
rating_value => $rating_value,
}
)->store;
}
my $ratings = Koha::Ratings->search( { biblionumber => $biblio->biblionumber } );
my $average = $ratings->get_avg_rating;
return $c->render(
status => 200,
openapi => {
rating => $rating && $rating->in_storage ? $rating->rating_value : undef,
average => $average,
count => $ratings->count
},
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 add
Controller function that handles creating a biblio object
=cut
sub add {
my $c = shift->openapi->valid_input or return;
try {
my $headers = $c->req->headers;
my $flavour = $headers->header('x-record-schema');
$flavour //= C4::Context->preference('marcflavour');
my $record_source_id = $headers->header('x-record-source-id');
if ($record_source_id) {
# We've been passed a record source. Verify they are allowed to
unless ( haspermission( $c->stash('koha.user')->userid, { editcatalogue => 'set_record_sources' } ) ) {
return $c->render(
status => 403,
openapi => { error => 'You do not have permission to set the record source' }
);
}
}
my $record;
my $frameworkcode = $headers->header('x-framework-id');
my $content_type = $headers->content_type;
if ( $content_type =~ m/application\/marcxml\+xml/ ) {
$record = MARC::Record->new_from_xml( $c->req->body, 'UTF-8', $flavour );
} elsif ( $content_type =~ m/application\/marc-in-json/ ) {
$record = MARC::Record->new_from_mij_structure( $c->req->json );
} elsif ( $content_type =~ m/application\/marc/ ) {
$record = MARC::Record->new_from_usmarc( $c->req->body );
} else {
return $c->render(
status => 406,
openapi => [
"application/marcxml+xml",
"application/marc-in-json",
"application/marc"
]
);
}
my $confirm_not_duplicate = $headers->header('x-confirm-not-duplicate');
if ( !$confirm_not_duplicate ) {
my ( $duplicatebiblionumber, $duplicatetitle ) = FindDuplicate($record);
return $c->render(
status => 400,
openapi => {
error => "Duplicate biblio $duplicatebiblionumber",
}
) if $duplicatebiblionumber;
}
my ($biblio_id) = C4::Biblio::AddBiblio( $record, $frameworkcode, { record_source_id => $record_source_id } );
if ( !$biblio_id ) {
# FIXME: AddBiblio wraps everything inside a transaction and a try/catch block
# this will need a tweak if this behavior changes
return $c->render(
status => 400,
openapi => {
error => 'Error creating record',
error_code => 'record_creation_failed',
},
);
}
$c->res->headers->location( $c->req->url->to_string . '/' . $biblio_id );
return $c->render(
status => 200,
openapi => { id => $biblio_id }
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 update
Controller function that handles modifying an biblio object
=cut
sub update {
my $c = shift->openapi->valid_input or return;
my $biblio = Koha::Biblios->find( $c->param('biblio_id') );
return $c->render_resource_not_found("Bibliographic record")
unless $biblio;
return $c->render(
status => 403,
openapi => { error => 'You do not have permission to edit a locked record' }
) unless ( $biblio->can_be_edited( $c->stash('koha.user') ) );
try {
my $headers = $c->req->headers;
my $flavour = $headers->header('x-record-schema');
$flavour //= C4::Context->preference('marcflavour');
my $frameworkcode = $headers->header('x-framework-id') || $biblio->frameworkcode;
my $content_type = $headers->content_type;
my $record;
if ( $content_type =~ m/application\/marcxml\+xml/ ) {
$record = MARC::Record->new_from_xml( $c->req->body, 'UTF-8', $flavour );
} elsif ( $content_type =~ m/application\/marc-in-json/ ) {
$record = MARC::Record->new_from_mij_structure( $c->req->json );
} elsif ( $content_type =~ m/application\/marc/ ) {
$record = MARC::Record->new_from_usmarc( $c->req->body );
} else {
return $c->render(
status => 406,
openapi => [
"application/json",
"application/marcxml+xml",
"application/marc-in-json",
"application/marc"
]
);
}
my $record_source_id = $headers->header('x-record-source-id');
my $options = {
overlay_context => {
userid => $c->stash('koha.user')->userid,
categorycode => $c->stash('koha.user')->categorycode,
}
};
if ($record_source_id) {
# We've been passed a record source. Verify they are allowed to
unless (
haspermission(
$c->stash('koha.user')->userid,
{ editcatalogue => 'set_record_sources' }
)
)
{
return $c->render(
status => 403,
openapi => { error => 'You do not have permission to set the record source' }
);
}
# find record source name to given id
my $record_source = Koha::RecordSources->search( { record_source_id => $record_source_id } )->single;
unless ($record_source) {
return $c->render(
status => 409,
openapi => { error => 'Given record_source_id does not exist' }
);
}
$options->{'overlay_context'}->{'source'} = $record_source->name;
# this will update the record source id in biblio metadata
$options->{'record_source_id'} = $record_source_id;
}
unless ( $options->{'overlay_context'}->{'source'} ) {
$options->{'overlay_context'}->{source} = '*';
}
ModBiblio( $record, $biblio->id, $frameworkcode, $options );
$c->render(
status => 200,
openapi => { id => $biblio->id }
);
} catch {
$c->unhandled_exception($_);
};
}
=head3 list
Controller function that handles retrieving a single biblio object
=cut
sub list {
my $c = shift->openapi->valid_input or return;
my @prefetch = qw(biblioitem);
push @prefetch, 'metadata' # don't prefetch metadata if not needed
unless $c->req->headers->accept =~ m/application\/json/;
my $rs = Koha::Biblios->search( undef, { prefetch => \@prefetch } );
my $biblios = $c->objects->search_rs( $rs, [ ( sub { $rs->api_query_fixer( $_[0], '', $_[1] ) } ) ] );
return try {
if ( $c->req->headers->accept =~ m/application\/json(;.*)?$/ ) {
return $c->render(
status => 200,
json => $c->objects->to_api($biblios),
);
} elsif ( $c->req->headers->accept =~ m/application\/marcxml\+xml(;.*)?$/ ) {
$c->res->headers->add( 'Content-Type', 'application/marcxml+xml' );
return $c->render(
status => 200,
text => $biblios->print_collection('marcxml')
);
} elsif ( $c->req->headers->accept =~ m/application\/marc-in-json(;.*)?$/ ) {
$c->res->headers->add( 'Content-Type', 'application/marc-in-json' );
return $c->render(
status => 200,
data => $biblios->print_collection('mij')
);
} elsif ( $c->req->headers->accept =~ m/application\/marc(;.*)?$/ ) {
$c->res->headers->add( 'Content-Type', 'application/marc' );
return $c->render(
status => 200,
text => $biblios->print_collection('marc')
);
} elsif ( $c->req->headers->accept =~ m/text\/plain(;.*)?$/ ) {
return $c->render(
status => 200,
text => $biblios->print_collection('txt')
);
} else {
return $c->render(
status => 406,
openapi => [
"application/json", "application/marcxml+xml",
"application/marc-in-json", "application/marc",
"text/plain"
]
);
}
} catch {
$c->unhandled_exception($_);
};
}
=head3 merge
Controller function that handles merging two biblios. If an optional
MARCXML is provided as the request body, this MARCXML replaces the
bibliodata of the merge target biblio. Syntax format inside the request body
must match with the Marc format used into Koha installation (MARC21 or UNIMARC)
=cut
sub merge {
my $c = shift->openapi->valid_input or return;
my $ref_biblionumber = $c->param('biblio_id');
my $json = decode_json( $c->req->body );
my $bn_merge = $json->{'biblio_id_to_merge'};
my $framework = $json->{'framework_to_use'} // q{};
my $rules = $json->{'rules'} || q{override};
my $override_rec = $json->{'datarecord'} // q{};
my $biblio = Koha::Biblios->find($ref_biblionumber);
if ( not defined $biblio ) {
return $c->render(
status => 404,
json => { error => sprintf( "[%s] biblio to merge into not found", $ref_biblionumber ) }
);
}
my $frombib = Koha::Biblios->find($bn_merge);
if ( not defined $frombib ) {
return $c->render(
status => 404,
json => { error => sprintf( "[%s] from which to merge not found", $bn_merge ) }
);
}
if ( ( $rules eq 'override_ext' ) && ( $override_rec eq '' ) ) {
return $c->render(
status => 404,
json => {
error =>
"With the rule 'override_ext' you need to insert a bib record in marc-in-json format into 'record' field."
}
);
}
if ( ( $rules eq 'override' ) && ( $framework ne '' ) ) {
return $c->render(
status => 404,
json => { error => "With the rule 'override' you can not use the field 'framework_to_use'." }
);
}
return try {
if ( $rules eq 'override_ext' ) {
my $record = MARC::Record::MiJ->new_from_mij_structure($override_rec);
$record->encoding('UTF-8');
$framework ||= $biblio->frameworkcode;
my $chk = ModBiblio( $record, $ref_biblionumber, $framework );
if ( $chk != 1 ) { die "Error on ModBiblio"; } # ModBiblio returns 1 if everything as gone well
}
$biblio->merge_with( [$bn_merge] );
$c->respond_to(
mij => {
status => 200,
format => 'mij',
data => $biblio->metadata->record->to_mij
}
);
} catch {
$c->render( status => 400, json => { error => $@ } );
};
}
1;