From: Bill Erickson Date: Mon, 31 Jan 2011 14:54:05 +0000 (-0500) Subject: Merge branch 'master' of git+ssh://yeti.esilibrary.com/home/evergreen/evergreen-equin... X-Git-Tag: opac-tt-poc-demo2~69^2~1 X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=dbd5c57bb0e3ea3a33022c55e0bbb661ea9a9743;p=evergreen%2Fequinox.git Merge branch 'master' of git+ssh://yeti.esilibrary.com/home/evergreen/evergreen-equinox into opac-tt-poc --- dbd5c57bb0e3ea3a33022c55e0bbb661ea9a9743 diff --cc Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm index 0000000000,c95bafe6f2..f2a20062a4 mode 000000,100644..100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm @@@ -1,0 -1,3356 +1,3363 @@@ + # --------------------------------------------------------------- + # Copyright (C) 2005 Georgia Public Library Service + # Bill Erickson + + # This program 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 2 + # of the License, or (at your option) any later version. + + # This program 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. + # --------------------------------------------------------------- + + + package OpenILS::Application::Circ::Holds; + use base qw/OpenILS::Application/; + use strict; use warnings; + use OpenILS::Application::AppUtils; + use DateTime; + use Data::Dumper; + use OpenSRF::EX qw(:try); + use OpenILS::Perm; + use OpenILS::Event; + use OpenSRF::Utils; + use OpenSRF::Utils::Logger qw(:logger); + use OpenILS::Utils::CStoreEditor q/:funcs/; + use OpenILS::Utils::PermitHold; + use OpenSRF::Utils::SettingsClient; + use OpenILS::Const qw/:const/; + use OpenILS::Application::Circ::Transit; + use OpenILS::Application::Actor::Friends; + use DateTime; + use DateTime::Format::ISO8601; + use OpenSRF::Utils qw/:datetime/; + use Digest::MD5 qw(md5_hex); + use OpenSRF::Utils::Cache; + my $apputils = "OpenILS::Application::AppUtils"; + my $U = $apputils; + + + __PACKAGE__->register_method( + method => "create_hold_batch", + api_name => "open-ils.circ.holds.create.batch", + stream => 1, + signature => { + desc => q/@see open-ils.circ.holds.create.batch/, + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Array of hold objects', type => 'array' } + ], + return => { + desc => 'Array of hold ID on success, -1 on missing arg, event (or ref to array of events) on error(s)', + }, + } + ); + + __PACKAGE__->register_method( + method => "create_hold_batch", + api_name => "open-ils.circ.holds.create.override.batch", + stream => 1, + signature => { + desc => '@see open-ils.circ.holds.create.batch', + } + ); + + + sub create_hold_batch { + my( $self, $conn, $auth, $hold_list ) = @_; + (my $method = $self->api_name) =~ s/\.batch//og; + foreach (@$hold_list) { + my ($res) = $self->method_lookup($method)->run($auth, $_); + $conn->respond($res); + } + return undef; + } + + + __PACKAGE__->register_method( + method => "create_hold", + api_name => "open-ils.circ.holds.create", + signature => { + desc => "Create a new hold for an item. From a permissions perspective, " . + "the login session is used as the 'requestor' of the hold. " . + "The hold recipient is determined by the 'usr' setting within the hold object. " . + 'First we verify the requestor has holds request permissions. ' . + 'Then we verify that the recipient is allowed to make the given hold. ' . + 'If not, we see if the requestor has "override" capabilities. If not, ' . + 'a permission exception is returned. If permissions allow, we cycle ' . + 'through the set of holds objects and create. ' . + 'If the recipient does not have permission to place multiple holds ' . + 'on a single title and said operation is attempted, a permission ' . + 'exception is returned', + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Hold object for hold to be created', + type => 'object', class => 'ahr' } + ], + return => { + desc => 'New ahr ID on success, -1 on missing arg, event (or ref to array of events) on error(s)', + }, + } + ); + + __PACKAGE__->register_method( + method => "create_hold", + api_name => "open-ils.circ.holds.create.override", + notes => '@see open-ils.circ.holds.create', + signature => { + desc => "If the recipient is not allowed to receive the requested hold, " . + "call this method to attempt the override", + params => [ + { desc => 'Authentication token', type => 'string' }, + { + desc => 'Hold object for hold to be created', + type => 'object', class => 'ahr' + } + ], + return => { + desc => 'New hold (ahr) ID on success, -1 on missing arg, event (or ref to array of events) on error(s)', + }, + } + ); + + sub create_hold { + my( $self, $conn, $auth, $hold ) = @_; + return -1 unless $hold; + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $override = 1 if $self->api_name =~ /override/; + + my @events; + + my $requestor = $e->requestor; + my $recipient = $requestor; + + if( $requestor->id ne $hold->usr ) { + # Make sure the requestor is allowed to place holds for + # the recipient if they are not the same people + $recipient = $e->retrieve_actor_user($hold->usr) or return $e->die_event; + $e->allowed('REQUEST_HOLDS', $recipient->home_ou) or return $e->die_event; + } + + # If the related org setting tells us to, block if patron privs have expired + my $expire_setting = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_BLOCK_HOLD_FOR_EXPIRED_PATRON); + if ($expire_setting) { + my $expire = DateTime::Format::ISO8601->new->parse_datetime( + cleanse_ISO8601($recipient->expire_date)); + + push( @events, OpenILS::Event->new( + 'PATRON_ACCOUNT_EXPIRED', + "payload" => {"fail_part" => "actor.usr.privs_expired"} + )) if( CORE::time > $expire->epoch ) ; + } + + # Now make sure the recipient is allowed to receive the specified hold + my $porg = $recipient->home_ou; + my $rid = $e->requestor->id; + my $t = $hold->hold_type; + + # See if a duplicate hold already exists + my $sargs = { + usr => $recipient->id, + hold_type => $t, + fulfillment_time => undef, + target => $hold->target, + cancel_time => undef, + }; + + $sargs->{holdable_formats} = $hold->holdable_formats if $t eq 'M'; + + my $existing = $e->search_action_hold_request($sargs); + push( @events, OpenILS::Event->new('HOLD_EXISTS')) if @$existing; + + my $checked_out = hold_item_is_checked_out($e, $recipient->id, $hold->hold_type, $hold->target); + push( @events, OpenILS::Event->new('HOLD_ITEM_CHECKED_OUT')) if $checked_out; + + if ( $t eq OILS_HOLD_TYPE_METARECORD ) { + return $e->die_event unless $e->allowed('MR_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_TITLE ) { + return $e->die_event unless $e->allowed('TITLE_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_VOLUME ) { + return $e->die_event unless $e->allowed('VOLUME_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_ISSUANCE ) { + return $e->die_event unless $e->allowed('ISSUANCE_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_COPY ) { + return $e->die_event unless $e->allowed('COPY_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_FORCE ) { + return $e->die_event unless $e->allowed('COPY_HOLDS', $porg); + } elsif ( $t eq OILS_HOLD_TYPE_RECALL ) { + return $e->die_event unless $e->allowed('COPY_HOLDS', $porg); + } + + if( @events ) { + if (!$override) { + $e->rollback; + return \@events; + } + for my $evt (@events) { + next unless $evt; + my $name = $evt->{textcode}; + return $e->die_event unless $e->allowed("$name.override", $porg); + } + } + + # set the configured expire time + unless($hold->expire_time) { + my $interval = $U->ou_ancestor_setting_value($recipient->home_ou, OILS_SETTING_HOLD_EXPIRE); + if($interval) { + my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval)); + $hold->expire_time($U->epoch2ISO8601($date->epoch)); + } + } + + $hold->requestor($e->requestor->id); + $hold->request_lib($e->requestor->ws_ou); + $hold->selection_ou($hold->pickup_lib) unless $hold->selection_ou; + $hold = $e->create_action_hold_request($hold) or return $e->die_event; + + $e->commit; + + $conn->respond_complete($hold->id); + + $U->storagereq( + 'open-ils.storage.action.hold_request.copy_targeter', + undef, $hold->id ) unless $U->is_true($hold->frozen); + + return undef; + } + + # makes sure that a user has permission to place the type of requested hold + # returns the Perm exception if not allowed, returns undef if all is well + sub _check_holds_perm { + my($type, $user_id, $org_id) = @_; + + my $evt; + if ($type eq "M") { + $evt = $apputils->check_perms($user_id, $org_id, "MR_HOLDS" ); + } elsif ($type eq "T") { + $evt = $apputils->check_perms($user_id, $org_id, "TITLE_HOLDS" ); + } elsif($type eq "V") { + $evt = $apputils->check_perms($user_id, $org_id, "VOLUME_HOLDS"); + } elsif($type eq "C") { + $evt = $apputils->check_perms($user_id, $org_id, "COPY_HOLDS" ); + } + + return $evt if $evt; + return undef; + } + + # tests if the given user is allowed to place holds on another's behalf + sub _check_request_holds_perm { + my $user_id = shift; + my $org_id = shift; + if (my $evt = $apputils->check_perms( + $user_id, $org_id, "REQUEST_HOLDS")) { + return $evt; + } + } + + my $ses_is_req_note = 'The login session is the requestor. If the requestor is different from the user, ' . + 'then the requestor must have VIEW_HOLD permissions'; + + __PACKAGE__->register_method( + method => "retrieve_holds_by_id", + api_name => "open-ils.circ.holds.retrieve_by_id", + signature => { + desc => "Retrieve the hold, with hold transits attached, for the specified ID. $ses_is_req_note", + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Hold ID', type => 'number' } + ], + return => { + desc => 'Hold object with transits attached, event on error', + } + } + ); + + + sub retrieve_holds_by_id { + my($self, $client, $auth, $hold_id) = @_; + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; + $e->allowed('VIEW_HOLD') or return $e->event; + + my $holds = $e->search_action_hold_request( + [ + { id => $hold_id , fulfillment_time => undef }, + { + order_by => { ahr => "request_time" }, + flesh => 1, + flesh_fields => {ahr => ['notes']} + } + ] + ); + + flesh_hold_transits($holds); + flesh_hold_notices($holds, $e); + return $holds; + } + + + __PACKAGE__->register_method( + method => "retrieve_holds", + api_name => "open-ils.circ.holds.retrieve", + signature => { + desc => "Retrieves all the holds, with hold transits attached, for the specified user. $ses_is_req_note", + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'User ID', type => 'integer' } + ], + return => { + desc => 'list of holds, event on error', + } + } + ); + + __PACKAGE__->register_method( + method => "retrieve_holds", + api_name => "open-ils.circ.holds.id_list.retrieve", + authoritative => 1, + signature => { + desc => "Retrieves all the hold IDs, for the specified user. $ses_is_req_note", + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'User ID', type => 'integer' } + ], + return => { + desc => 'list of holds, event on error', + } + } + ); + + __PACKAGE__->register_method( + method => "retrieve_holds", + api_name => "open-ils.circ.holds.canceled.retrieve", + authoritative => 1, + signature => { + desc => "Retrieves all the cancelled holds for the specified user. $ses_is_req_note", + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'User ID', type => 'integer' } + ], + return => { + desc => 'list of holds, event on error', + } + } + ); + + __PACKAGE__->register_method( + method => "retrieve_holds", + api_name => "open-ils.circ.holds.canceled.id_list.retrieve", + authoritative => 1, + signature => { + desc => "Retrieves list of cancelled hold IDs for the specified user. $ses_is_req_note", + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'User ID', type => 'integer' } + ], + return => { + desc => 'list of hold IDs, event on error', + } + } + ); + + + sub retrieve_holds { + my ($self, $client, $auth, $user_id) = @_; + + my $e = new_editor(authtoken=>$auth); + return $e->event unless $e->checkauth; + $user_id = $e->requestor->id unless defined $user_id; + + my $notes_filter = {staff => 'f'}; + my $user = $e->retrieve_actor_user($user_id) or return $e->event; + unless($user_id == $e->requestor->id) { + if($e->allowed('VIEW_HOLD', $user->home_ou)) { + $notes_filter = {staff => 't'} + } else { + my $allowed = OpenILS::Application::Actor::Friends->friend_perm_allowed( + $e, $user_id, $e->requestor->id, 'hold.view'); + return $e->event unless $allowed; + } + } else { + # staff member looking at his/her own holds can see staff and non-staff notes + $notes_filter = {} if $e->allowed('VIEW_HOLD', $user->home_ou); + } + + my $holds_query = { + select => {ahr => ['id']}, + from => 'ahr', + where => {usr => $user_id, fulfillment_time => undef} + }; + + if($self->api_name =~ /canceled/) { + + # Fetch the canceled holds + # order cancelled holds by cancel time, most recent first + + $holds_query->{order_by} = [{class => 'ahr', field => 'cancel_time', direction => 'desc'}]; + + my $cancel_age; + my $cancel_count = $U->ou_ancestor_setting_value( + $e->requestor->ws_ou, 'circ.holds.canceled.display_count', $e); + + unless($cancel_count) { + $cancel_age = $U->ou_ancestor_setting_value( + $e->requestor->ws_ou, 'circ.holds.canceled.display_age', $e); + + # if no settings are defined, default to last 10 cancelled holds + $cancel_count = 10 unless $cancel_age; + } + + if($cancel_count) { # limit by count + + $holds_query->{where}->{cancel_time} = {'!=' => undef}; + $holds_query->{limit} = $cancel_count; + + } elsif($cancel_age) { # limit by age + + # find all of the canceled holds that were canceled within the configured time frame + my $date = DateTime->now->subtract(seconds => OpenSRF::Utils::interval_to_seconds($cancel_age)); + $date = $U->epoch2ISO8601($date->epoch); + $holds_query->{where}->{cancel_time} = {'>=' => $date}; + } + + } else { + + # order non-cancelled holds by ready-for-pickup, then active, followed by suspended + $holds_query->{order_by} = {ahr => ['shelf_time', 'frozen', 'request_time']}; + $holds_query->{where}->{cancel_time} = undef; + } + + my $hold_ids = $e->json_query($holds_query); + $hold_ids = [ map { $_->{id} } @$hold_ids ]; + + return $hold_ids if $self->api_name =~ /id_list/; + + my @holds; + for my $hold_id ( @$hold_ids ) { + + my $hold = $e->retrieve_action_hold_request($hold_id); + $hold->notes($e->search_action_hold_request_note({hold => $hold_id, %$notes_filter})); + + $hold->transit( + $e->search_action_hold_transit_copy([ + {hold => $hold->id}, + {order_by => {ahtc => 'source_send_time desc'}, limit => 1}])->[0] + ); + + push(@holds, $hold); + } + + return \@holds; + } + + + __PACKAGE__->register_method( + method => 'user_hold_count', + api_name => 'open-ils.circ.hold.user.count' + ); + + sub user_hold_count { + my ( $self, $conn, $auth, $userid ) = @_; + my $e = new_editor( authtoken => $auth ); + return $e->event unless $e->checkauth; + my $patron = $e->retrieve_actor_user($userid) + or return $e->event; + return $e->event unless $e->allowed( 'VIEW_HOLD', $patron->home_ou ); + return __user_hold_count( $self, $e, $userid ); + } + + sub __user_hold_count { + my ( $self, $e, $userid ) = @_; + my $holds = $e->search_action_hold_request( + { + usr => $userid, + fulfillment_time => undef, + cancel_time => undef, + }, + { idlist => 1 } + ); + + return scalar(@$holds); + } + + + __PACKAGE__->register_method( + method => "retrieve_holds_by_pickup_lib", + api_name => "open-ils.circ.holds.retrieve_by_pickup_lib", + notes => + "Retrieves all the holds, with hold transits attached, for the specified pickup_ou id." + ); + + __PACKAGE__->register_method( + method => "retrieve_holds_by_pickup_lib", + api_name => "open-ils.circ.holds.id_list.retrieve_by_pickup_lib", + notes => "Retrieves all the hold ids for the specified pickup_ou id. " + ); + + sub retrieve_holds_by_pickup_lib { + my ($self, $client, $login_session, $ou_id) = @_; + + #FIXME -- put an appropriate permission check here + #my( $user, $target, $evt ) = $apputils->checkses_requestor( + # $login_session, $user_id, 'VIEW_HOLD' ); + #return $evt if $evt; + + my $holds = $apputils->simplereq( + 'open-ils.cstore', + "open-ils.cstore.direct.action.hold_request.search.atomic", + { + pickup_lib => $ou_id , + fulfillment_time => undef, + cancel_time => undef + }, + { order_by => { ahr => "request_time" } } + ); + + if ( ! $self->api_name =~ /id_list/ ) { + flesh_hold_transits($holds); + return $holds; + } + # else id_list + return [ map { $_->id } @$holds ]; + } + + + __PACKAGE__->register_method( + method => "uncancel_hold", + api_name => "open-ils.circ.hold.uncancel" + ); + + sub uncancel_hold { + my($self, $client, $auth, $hold_id) = @_; + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $hold = $e->retrieve_action_hold_request($hold_id) + or return $e->die_event; + return $e->die_event unless $e->allowed('CANCEL_HOLDS', $hold->request_lib); + + if ($hold->fulfillment_time) { + $e->rollback; + return 0; + } + unless ($hold->cancel_time) { + $e->rollback; + return 1; + } + + # if configured to reset the request time, also reset the expire time + if($U->ou_ancestor_setting_value( + $hold->request_lib, 'circ.holds.uncancel.reset_request_time', $e)) { + + $hold->request_time('now'); + my $interval = $U->ou_ancestor_setting_value($hold->request_lib, OILS_SETTING_HOLD_EXPIRE); + if($interval) { + my $date = DateTime->now->add(seconds => OpenSRF::Utils::interval_to_seconds($interval)); + $hold->expire_time($U->epoch2ISO8601($date->epoch)); + } + } + + $hold->clear_cancel_time; + $hold->clear_cancel_cause; + $hold->clear_cancel_note; + $e->update_action_hold_request($hold) or return $e->die_event; + $e->commit; + + $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id); + + return 1; + } + + + __PACKAGE__->register_method( + method => "cancel_hold", + api_name => "open-ils.circ.hold.cancel", + signature => { + desc => 'Cancels the specified hold. The login session is the requestor. If the requestor is different from the usr field ' . + 'on the hold, the requestor must have CANCEL_HOLDS permissions. The hold may be either the hold object or the hold id', + param => [ + {desc => 'Authentication token', type => 'string'}, + {desc => 'Hold ID', type => 'number'}, + {desc => 'Cause of Cancellation', type => 'string'}, + {desc => 'Note', type => 'string'} + ], + return => { + desc => '1 on success, event on error' + } + } + ); + + sub cancel_hold { + my($self, $client, $auth, $holdid, $cause, $note) = @_; + + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $hold = $e->retrieve_action_hold_request($holdid) + or return $e->die_event; + + if( $e->requestor->id ne $hold->usr ) { + return $e->die_event unless $e->allowed('CANCEL_HOLDS'); + } + + if ($hold->cancel_time) { + $e->rollback; + return 1; + } + + # If the hold is captured, reset the copy status + if( $hold->capture_time and $hold->current_copy ) { + + my $copy = $e->retrieve_asset_copy($hold->current_copy) + or return $e->die_event; + + if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) { + $logger->info("canceling hold $holdid whose item is on the holds shelf"); + # $logger->info("setting copy to status 'reshelving' on hold cancel"); + # $copy->status(OILS_COPY_STATUS_RESHELVING); + # $copy->editor($e->requestor->id); + # $copy->edit_date('now'); + # $e->update_asset_copy($copy) or return $e->event; + + } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) { + + my $hid = $hold->id; + $logger->warn("! canceling hold [$hid] that is in transit"); + my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0]; + + if( $transid ) { + my $trans = $e->retrieve_action_transit_copy($transid); + # Leave the transit alive, but set the copy status to + # reshelving so it will be properly reshelved when it gets back home + if( $trans ) { + $trans->copy_status( OILS_COPY_STATUS_RESHELVING ); + $e->update_action_transit_copy($trans) or return $e->die_event; + } + } + } + } + + $hold->cancel_time('now'); + $hold->cancel_cause($cause); + $hold->cancel_note($note); + $e->update_action_hold_request($hold) + or return $e->die_event; + + delete_hold_copy_maps($self, $e, $hold->id); + + $e->commit; + + $U->create_events_for_hook('hold_request.cancel.staff', $hold, $hold->pickup_lib) + if $e->requestor->id != $hold->usr; + + return 1; + } + + sub delete_hold_copy_maps { + my $class = shift; + my $editor = shift; + my $holdid = shift; + + my $maps = $editor->search_action_hold_copy_map({hold=>$holdid}); + for(@$maps) { + $editor->delete_action_hold_copy_map($_) + or return $editor->event; + } + return undef; + } + + + my $update_hold_desc = 'The login session is the requestor. ' . + 'If the requestor is different from the usr field on the hold, ' . + 'the requestor must have UPDATE_HOLDS permissions. ' . + 'If supplying a hash of hold data, "id" must be included. ' . + 'The hash is ignored if a hold object is supplied, ' . + 'so you should supply only one kind of hold data argument.' ; + + __PACKAGE__->register_method( + method => "update_hold", + api_name => "open-ils.circ.hold.update", + signature => { + desc => "Updates the specified hold. $update_hold_desc", + params => [ + {desc => 'Authentication token', type => 'string'}, + {desc => 'Hold Object', type => 'object'}, + {desc => 'Hash of values to be applied', type => 'object'} + ], + return => { + desc => 'Hold ID on success, event on error', + # type => 'number' + } + } + ); + + __PACKAGE__->register_method( + method => "batch_update_hold", + api_name => "open-ils.circ.hold.update.batch", + stream => 1, + signature => { + desc => "Updates the specified hold(s). $update_hold_desc", + params => [ + {desc => 'Authentication token', type => 'string'}, + {desc => 'Array of hold obejcts', type => 'array' }, + {desc => 'Array of hashes of values to be applied', type => 'array' } + ], + return => { + desc => 'Hold ID per success, event per error', + } + } + ); + + sub update_hold { + my($self, $client, $auth, $hold, $values) = @_; + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + my $resp = update_hold_impl($self, $e, $hold, $values); + if ($U->event_code($resp)) { + $e->rollback; + return $resp; + } + $e->commit; # FIXME: update_hold_impl already does $e->commit ?? + return $resp; + } + + sub batch_update_hold { + my($self, $client, $auth, $hold_list, $values_list) = @_; + my $e = new_editor(authtoken=>$auth); + return $e->die_event unless $e->checkauth; + + my $count = ($hold_list) ? scalar(@$hold_list) : scalar(@$values_list); # FIXME: we don't know for sure that we got $values_list. we could have neither list. + $hold_list ||= []; + $values_list ||= []; # FIXME: either move this above $count declaration, or send an event if both lists undef. Probably the latter. + + # FIXME: Failing over to [] guarantees warnings for "Use of unitialized value" in update_hold_impl call. + # FIXME: We should be sure we only call update_hold_impl with hold object OR hash, not both. + + for my $idx (0..$count-1) { + $e->xact_begin; + my $resp = update_hold_impl($self, $e, $hold_list->[$idx], $values_list->[$idx]); + $e->xact_commit unless $U->event_code($resp); + $client->respond($resp); + } + + $e->disconnect; + return undef; # not in the register return type, assuming we should always have at least one list populated + } + + sub update_hold_impl { + my($self, $e, $hold, $values) = @_; + + unless($hold) { + $hold = $e->retrieve_action_hold_request($values->{id}) + or return $e->die_event; + for my $k (keys %$values) { + if (defined $values->{$k}) { + $hold->$k($values->{$k}); + } else { + my $f = "clear_$k"; $hold->$f(); + } + } + } + + my $orig_hold = $e->retrieve_action_hold_request($hold->id) + or return $e->die_event; + + # don't allow the user to be changed + return OpenILS::Event->new('BAD_PARAMS') if $hold->usr != $orig_hold->usr; + + if($hold->usr ne $e->requestor->id) { + # if the hold is for a different user, make sure the + # requestor has the appropriate permissions + my $usr = $e->retrieve_actor_user($hold->usr) + or return $e->die_event; + return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou); + } + + + # -------------------------------------------------------------- + # Changing the request time is like playing God + # -------------------------------------------------------------- + if($hold->request_time ne $orig_hold->request_time) { + return OpenILS::Event->new('BAD_PARAMS') if $hold->fulfillment_time; + return $e->die_event unless $e->allowed('UPDATE_HOLD_REQUEST_TIME', $hold->pickup_lib); + } + + # -------------------------------------------------------------- + # if the hold is on the holds shelf or in transit and the pickup + # lib changes we need to create a new transit. + # -------------------------------------------------------------- + if($orig_hold->pickup_lib ne $hold->pickup_lib) { + + my $status = _hold_status($e, $hold); + + if($status == 3) { # in transit + + return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $orig_hold->pickup_lib); + return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_TRANSIT', $hold->pickup_lib); + + $logger->info("updating pickup lib for hold ".$hold->id." while already in transit"); + + # update the transit to reflect the new pickup location + my $transit = $e->search_action_hold_transit_copy( + {hold=>$hold->id, dest_recv_time => undef})->[0] + or return $e->die_event; + + $transit->prev_dest($transit->dest); # mark the previous destination on the transit + $transit->dest($hold->pickup_lib); + $e->update_action_hold_transit_copy($transit) or return $e->die_event; + + } elsif($status == 4) { # on holds shelf + + return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $orig_hold->pickup_lib); + return $e->die_event unless $e->allowed('UPDATE_PICKUP_LIB_FROM_HOLDS_SHELF', $hold->pickup_lib); + + $logger->info("updating pickup lib for hold ".$hold->id." while on holds shelf"); + + # create the new transit + my $evt = transit_hold($e, $orig_hold, $hold, $e->retrieve_asset_copy($hold->current_copy)); + return $evt if $evt; + } + } + + update_hold_if_frozen($self, $e, $hold, $orig_hold); + $e->update_action_hold_request($hold) or return $e->die_event; + $e->commit; + + # a change to mint-condition changes the set of potential copies, so retarget the hold; + if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) { + _reset_hold($self, $e->requestor, $hold) + } + + return $hold->id; + } + + sub transit_hold { + my($e, $orig_hold, $hold, $copy) = @_; + my $src = $orig_hold->pickup_lib; + my $dest = $hold->pickup_lib; + + $logger->info("putting hold into transit on pickup_lib update"); + + my $transit = Fieldmapper::action::hold_transit_copy->new; + $transit->hold($hold->id); + $transit->source($src); + $transit->dest($dest); + $transit->target_copy($copy->id); + $transit->source_send_time('now'); + $transit->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF); + + $copy->status(OILS_COPY_STATUS_IN_TRANSIT); + $copy->editor($e->requestor->id); + $copy->edit_date('now'); + + $e->create_action_hold_transit_copy($transit) or return $e->die_event; + $e->update_asset_copy($copy) or return $e->die_event; + return undef; + } + + # if the hold is frozen, this method ensures that the hold is not "targeted", + # that is, it clears the current_copy and prev_check_time to essentiallly + # reset the hold. If it is being activated, it runs the targeter in the background + sub update_hold_if_frozen { + my($self, $e, $hold, $orig_hold) = @_; + return if $hold->capture_time; + + if($U->is_true($hold->frozen)) { + $logger->info("clearing current_copy and check_time for frozen hold ".$hold->id); + $hold->clear_current_copy; + $hold->clear_prev_check_time; + + } else { + if($U->is_true($orig_hold->frozen)) { + $logger->info("Running targeter on activated hold ".$hold->id); + $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id ); + } + } + } + + __PACKAGE__->register_method( + method => "hold_note_CUD", + api_name => "open-ils.circ.hold_request.note.cud", + signature => { + desc => 'Create, update or delete a hold request note. If the operator (from Auth. token) ' + . 'is not the owner of the hold, the UPDATE_HOLD permission is required', + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Hold note object', type => 'object' } + ], + return => { + desc => 'Returns the note ID, event on error' + }, + } + ); + + sub hold_note_CUD { + my($self, $conn, $auth, $note) = @_; + + my $e = new_editor(authtoken => $auth, xact => 1); + return $e->die_event unless $e->checkauth; + + my $hold = $e->retrieve_action_hold_request($note->hold) + or return $e->die_event; + + if($hold->usr ne $e->requestor->id) { + my $usr = $e->retrieve_actor_user($hold->usr); + return $e->die_event unless $e->allowed('UPDATE_HOLD', $usr->home_ou); + $note->staff('t') if $note->isnew; + } + + if($note->isnew) { + $e->create_action_hold_request_note($note) or return $e->die_event; + } elsif($note->ischanged) { + $e->update_action_hold_request_note($note) or return $e->die_event; + } elsif($note->isdeleted) { + $e->delete_action_hold_request_note($note) or return $e->die_event; + } + + $e->commit; + return $note->id; + } + + + __PACKAGE__->register_method( + method => "retrieve_hold_status", + api_name => "open-ils.circ.hold.status.retrieve", + signature => { + desc => 'Calculates the current status of the hold. The requestor must have ' . + 'VIEW_HOLD permissions if the hold is for a user other than the requestor' , + param => [ + { desc => 'Hold ID', type => 'number' } + ], + return => { + # type => 'number', # event sometimes + desc => <<'END_OF_DESC' + Returns event on error or: + -1 on error (for now), + 1 for 'waiting for copy to become available', + 2 for 'waiting for copy capture', + 3 for 'in transit', + 4 for 'arrived', + 5 for 'hold-shelf-delay' + 6 for 'canceled' + END_OF_DESC + } + } + ); + + sub retrieve_hold_status { + my($self, $client, $auth, $hold_id) = @_; + + my $e = new_editor(authtoken => $auth); + return $e->event unless $e->checkauth; + my $hold = $e->retrieve_action_hold_request($hold_id) + or return $e->event; + + if( $e->requestor->id != $hold->usr ) { + return $e->event unless $e->allowed('VIEW_HOLD'); + } + + return _hold_status($e, $hold); + + } + + sub _hold_status { + my($e, $hold) = @_; + if ($hold->cancel_time) { + return 6; + } + return 1 unless $hold->current_copy; + return 2 unless $hold->capture_time; + + my $copy = $hold->current_copy; + unless( ref $copy ) { + $copy = $e->retrieve_asset_copy($hold->current_copy) + or return $e->event; + } + + return 3 if $copy->status == OILS_COPY_STATUS_IN_TRANSIT; + + if($copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF) { + + my $hs_wait_interval = $U->ou_ancestor_setting_value($hold->pickup_lib, 'circ.hold_shelf_status_delay'); + return 4 unless $hs_wait_interval; + + # if a hold_shelf_status_delay interval is defined and start_time plus + # the interval is greater than now, consider the hold to be in the virtual + # "on its way to the holds shelf" status. Return 5. + + my $transit = $e->search_action_hold_transit_copy({hold => $hold->id})->[0]; + my $start_time = ($transit) ? $transit->dest_recv_time : $hold->capture_time; + $start_time = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)); + my $end_time = $start_time->add(seconds => OpenSRF::Utils::interval_to_seconds($hs_wait_interval)); + + return 5 if $end_time > DateTime->now; + return 4; + } + + return -1; # error + } + + + + __PACKAGE__->register_method( + method => "retrieve_hold_queue_stats", + api_name => "open-ils.circ.hold.queue_stats.retrieve", + signature => { + desc => 'Returns summary data about the state of a hold', + params => [ + { desc => 'Authentication token', type => 'string'}, + { desc => 'Hold ID', type => 'number'}, + ], + return => { + desc => q/Summary object with keys: + total_holds : total holds in queue + queue_position : current queue position + potential_copies : number of potential copies for this hold + estimated_wait : estimated wait time in days + status : hold status + -1 => error or unexpected state, + 1 => 'waiting for copy to become available', + 2 => 'waiting for copy capture', + 3 => 'in transit', + 4 => 'arrived', + 5 => 'hold-shelf-delay' + /, + type => 'object' + } + } + ); + + sub retrieve_hold_queue_stats { + my($self, $conn, $auth, $hold_id) = @_; + my $e = new_editor(authtoken => $auth); + return $e->event unless $e->checkauth; + my $hold = $e->retrieve_action_hold_request($hold_id) or return $e->event; + if($e->requestor->id != $hold->usr) { + return $e->event unless $e->allowed('VIEW_HOLD'); + } + return retrieve_hold_queue_status_impl($e, $hold); + } + + sub retrieve_hold_queue_status_impl { + my $e = shift; + my $hold = shift; + + # The holds queue is defined as the distinct set of holds that share at + # least one potential copy with the context hold, plus any holds that + # share the same hold type and target. The latter part exists to + # accomodate holds that currently have no potential copies + my $q_holds = $e->json_query({ + + # fetch cut_in_line and request_time since they're in the order_by + # and we're asking for distinct values + select => {ahr => ['id', 'cut_in_line', 'request_time']}, + from => { ahr => 'ahcm' }, + order_by => [ + { + "class" => "ahr", + "field" => "cut_in_line", + "transform" => "coalesce", + "params" => [ 0 ], + "direction" => "desc" + }, + { "class" => "ahr", "field" => "request_time" } + ], + distinct => 1, + where => { + '+ahcm' => { + target_copy => { + in => { + select => {ahcm => ['target_copy']}, + from => 'ahcm', + where => {hold => $hold->id} + } + } + } + } + }); + + if (!@$q_holds) { # none? maybe we don't have a map ... + $q_holds = $e->json_query({ + select => {ahr => ['id', 'cut_in_line', 'request_time']}, + from => 'ahr', + order_by => [ + { + "class" => "ahr", + "field" => "cut_in_line", + "transform" => "coalesce", + "params" => [ 0 ], + "direction" => "desc" + }, + { "class" => "ahr", "field" => "request_time" } + ], + where => { + hold_type => $hold->hold_type, + target => $hold->target + } + }); + } + + + my $qpos = 1; + for my $h (@$q_holds) { + last if $h->{id} == $hold->id; + $qpos++; + } + + my $hold_data = $e->json_query({ + select => { + acp => [ {column => 'id', transform => 'count', aggregate => 1, alias => 'count'} ], + ccm => [ {column =>'avg_wait_time'} ] + }, + from => { + ahcm => { + acp => { + join => { + ccm => {type => 'left'} + } + } + } + }, + where => {'+ahcm' => {hold => $hold->id} } + }); + + my $user_org = $e->json_query({select => {au => ['home_ou']}, from => 'au', where => {id => $hold->usr}})->[0]->{home_ou}; + + my $default_wait = $U->ou_ancestor_setting_value($user_org, OILS_SETTING_HOLD_ESIMATE_WAIT_INTERVAL); + my $min_wait = $U->ou_ancestor_setting_value($user_org, 'circ.holds.min_estimated_wait_interval'); + $min_wait = OpenSRF::Utils::interval_to_seconds($min_wait || '0 seconds'); + $default_wait ||= '0 seconds'; + + # Estimated wait time is the average wait time across the set + # of potential copies, divided by the number of potential copies + # times the queue position. + + my $combined_secs = 0; + my $num_potentials = 0; + + for my $wait_data (@$hold_data) { + my $count += $wait_data->{count}; + $combined_secs += $count * + OpenSRF::Utils::interval_to_seconds($wait_data->{avg_wait_time} || $default_wait); + $num_potentials += $count; + } + + my $estimated_wait = -1; + + if($num_potentials) { + my $avg_wait = $combined_secs / $num_potentials; + $estimated_wait = $qpos * ($avg_wait / $num_potentials); + $estimated_wait = $min_wait if $estimated_wait < $min_wait and $estimated_wait != -1; + } + + return { + total_holds => scalar(@$q_holds), + queue_position => $qpos, + potential_copies => $num_potentials, + status => _hold_status( $e, $hold ), + estimated_wait => int($estimated_wait) + }; + } + + + sub fetch_open_hold_by_current_copy { + my $class = shift; + my $copyid = shift; + my $hold = $apputils->simplereq( + 'open-ils.cstore', + 'open-ils.cstore.direct.action.hold_request.search.atomic', + { current_copy => $copyid , cancel_time => undef, fulfillment_time => undef }); + return $hold->[0] if ref($hold); + return undef; + } + + sub fetch_related_holds { + my $class = shift; + my $copyid = shift; + return $apputils->simplereq( + 'open-ils.cstore', + 'open-ils.cstore.direct.action.hold_request.search.atomic', + { current_copy => $copyid , cancel_time => undef, fulfillment_time => undef }); + } + + + __PACKAGE__->register_method( + method => "hold_pull_list", + api_name => "open-ils.circ.hold_pull_list.retrieve", + signature => { + desc => 'Returns (reference to) a list of holds that need to be "pulled" by a given location. ' . + 'The location is determined by the login session.', + params => [ + { desc => 'Limit (optional)', type => 'number'}, + { desc => 'Offset (optional)', type => 'number'}, + ], + return => { + desc => 'reference to a list of holds, or event on failure', + } + } + ); + + __PACKAGE__->register_method( + method => "hold_pull_list", + api_name => "open-ils.circ.hold_pull_list.id_list.retrieve", + signature => { + desc => 'Returns (reference to) a list of holds IDs that need to be "pulled" by a given location. ' . + 'The location is determined by the login session.', + params => [ + { desc => 'Limit (optional)', type => 'number'}, + { desc => 'Offset (optional)', type => 'number'}, + ], + return => { + desc => 'reference to a list of holds, or event on failure', + } + } + ); + + __PACKAGE__->register_method( + method => "hold_pull_list", + api_name => "open-ils.circ.hold_pull_list.retrieve.count", + signature => { + desc => 'Returns a count of holds that need to be "pulled" by a given location. ' . + 'The location is determined by the login session.', + params => [ + { desc => 'Limit (optional)', type => 'number'}, + { desc => 'Offset (optional)', type => 'number'}, + ], + return => { + desc => 'Holds count (integer), or event on failure', + # type => 'number' + } + } + ); + + + sub hold_pull_list { + my( $self, $conn, $authtoken, $limit, $offset ) = @_; + my( $reqr, $evt ) = $U->checkses($authtoken); + return $evt if $evt; + + my $org = $reqr->ws_ou || $reqr->home_ou; + # the perm locaiton shouldn't really matter here since holds + # will exist all over and VIEW_HOLDS should be universal + $evt = $U->check_perms($reqr->id, $org, 'VIEW_HOLD'); + return $evt if $evt; + + if($self->api_name =~ /count/) { + + my $count = $U->storagereq( + 'open-ils.storage.direct.action.hold_request.pull_list.current_copy_circ_lib.status_filtered.count', + $org, $limit, $offset ); + + $logger->info("Grabbing pull list for org unit $org with $count items"); + return $count; + + } elsif( $self->api_name =~ /id_list/ ) { + return $U->storagereq( + 'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic', + $org, $limit, $offset ); + + } else { + return $U->storagereq( + 'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic', + $org, $limit, $offset ); + } + } + + __PACKAGE__->register_method( + method => "print_hold_pull_list", + api_name => "open-ils.circ.hold_pull_list.print", + signature => { + desc => 'Returns an HTML-formatted holds pull list', + params => [ + { desc => 'Authtoken', type => 'string'}, + { desc => 'Org unit ID. Optional, defaults to workstation org unit', type => 'number'}, + ], + return => { + desc => 'HTML string', + type => 'string' + } + } + ); + + sub print_hold_pull_list { + my($self, $client, $auth, $org_id) = @_; + + my $e = new_editor(authtoken=>$auth); + return $e->event unless $e->checkauth; + + $org_id = (defined $org_id) ? $org_id : $e->requestor->ws_ou; + return $e->event unless $e->allowed('VIEW_HOLD', $org_id); + + my $hold_ids = $U->storagereq( + 'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic', + $org_id, 10000); + + return undef unless @$hold_ids; + + $client->status(new OpenSRF::DomainObject::oilsContinueStatus); + + # Holds will /NOT/ be in order after this ... + my $holds = $e->search_action_hold_request({id => $hold_ids}, {substream => 1}); + $client->status(new OpenSRF::DomainObject::oilsContinueStatus); + + # ... so we must resort. + my $hold_map = +{map { $_->id => $_ } @$holds}; + my $sorted_holds = []; + push @$sorted_holds, $hold_map->{$_} foreach @$hold_ids; + + return $U->fire_object_event( + undef, "ahr.format.pull_list", $sorted_holds, + $org_id, undef, undef, $client + ); + + } + + __PACKAGE__->register_method( + method => "print_hold_pull_list_stream", + stream => 1, + api_name => "open-ils.circ.hold_pull_list.print.stream", + signature => { + desc => 'Returns a stream of fleshed holds', + params => [ + { desc => 'Authtoken', type => 'string'}, + { desc => 'Hash of optional param: Org unit ID (defaults to workstation org unit), limit, offset, sort (array of: acplo.position, call_number, request_time)', + type => 'object' + }, + ], + return => { + desc => 'A stream of fleshed holds', + type => 'object' + } + } + ); + + sub print_hold_pull_list_stream { + my($self, $client, $auth, $params) = @_; + + my $e = new_editor(authtoken=>$auth); + return $e->die_event unless $e->checkauth; + + delete($$params{org_id}) unless (int($$params{org_id})); + delete($$params{limit}) unless (int($$params{limit})); + delete($$params{offset}) unless (int($$params{offset})); + delete($$params{chunk_size}) unless (int($$params{chunk_size})); + delete($$params{chunk_size}) if ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable + $$params{chunk_size} ||= 10; + + $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou; + return $e->die_event unless $e->allowed('VIEW_HOLD', $$params{org_id }); + + my $sort = []; + if ($$params{sort} && @{ $$params{sort} }) { + for my $s (@{ $$params{sort} }) { + if ($s eq 'acplo.position') { + push @$sort, { + "class" => "acplo", "field" => "position", + "transform" => "coalesce", "params" => [999] + }; + } elsif ($s eq 'call_number') { + push @$sort, {"class" => "acn", "field" => "label"}; + } elsif ($s eq 'request_time') { + push @$sort, {"class" => "ahr", "field" => "request_time"}; + } + } + } else { + push @$sort, {"class" => "ahr", "field" => "request_time"}; + } + + my $holds_ids = $e->json_query( + { + "select" => {"ahr" => ["id"]}, + "from" => { + "ahr" => { + "acp" => { + "field" => "id", + "fkey" => "current_copy", + "filter" => { + "circ_lib" => $$params{org_id}, "status" => [0,7] + }, + "join" => { + "acn" => { + "field" => "id", + "fkey" => "call_number" + }, + "acplo" => { + "field" => "org", + "fkey" => "circ_lib", + "type" => "left", + "filter" => { + "location" => {"=" => {"+acp" => "location"}} + } + } + } + } + } + }, + "where" => { + "+ahr" => { + "capture_time" => undef, + "cancel_time" => undef, + "-or" => [ + {"expire_time" => undef }, + {"expire_time" => {">" => "now"}} + ] + } + }, + (@$sort ? (order_by => $sort) : ()), + ($$params{limit} ? (limit => $$params{limit}) : ()), + ($$params{offset} ? (offset => $$params{offset}) : ()) + }, {"substream" => 1} + ) or return $e->die_event; + + $logger->info("about to stream back " . scalar(@$holds_ids) . " holds"); + + my @chunk; + for my $hid (@$holds_ids) { + push @chunk, $e->retrieve_action_hold_request([ + $hid->{"id"}, { + "flesh" => 3, + "flesh_fields" => { + "ahr" => ["usr", "current_copy"], + "au" => ["card"], + "acp" => ["location", "call_number"], + "acn" => ["record"] + } + } + ]); + + if (@chunk >= $$params{chunk_size}) { + $client->respond( \@chunk ); + @chunk = (); + } + } + $client->respond_complete( \@chunk ) if (@chunk); + $e->disconnect; + return undef; + } + + + + __PACKAGE__->register_method( + method => 'fetch_hold_notify', + api_name => 'open-ils.circ.hold_notification.retrieve_by_hold', + authoritative => 1, + signature => q/ + Returns a list of hold notification objects based on hold id. + @param authtoken The loggin session key + @param holdid The id of the hold whose notifications we want to retrieve + @return An array of hold notification objects, event on error. + / + ); + + sub fetch_hold_notify { + my( $self, $conn, $authtoken, $holdid ) = @_; + my( $requestor, $evt ) = $U->checkses($authtoken); + return $evt if $evt; + my ($hold, $patron); + ($hold, $evt) = $U->fetch_hold($holdid); + return $evt if $evt; + ($patron, $evt) = $U->fetch_user($hold->usr); + return $evt if $evt; + + $evt = $U->check_perms($requestor->id, $patron->home_ou, 'VIEW_HOLD_NOTIFICATION'); + return $evt if $evt; + + $logger->info("User ".$requestor->id." fetching hold notifications for hold $holdid"); + return $U->cstorereq( + 'open-ils.cstore.direct.action.hold_notification.search.atomic', {hold => $holdid} ); + } + + + __PACKAGE__->register_method( + method => 'create_hold_notify', + api_name => 'open-ils.circ.hold_notification.create', + signature => q/ + Creates a new hold notification object + @param authtoken The login session key + @param notification The hold notification object to create + @return ID of the new object on success, Event on error + / + ); + + sub create_hold_notify { + my( $self, $conn, $auth, $note ) = @_; + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $hold = $e->retrieve_action_hold_request($note->hold) + or return $e->die_event; + my $patron = $e->retrieve_actor_user($hold->usr) + or return $e->die_event; + + return $e->die_event unless + $e->allowed('CREATE_HOLD_NOTIFICATION', $patron->home_ou); + + $note->notify_staff($e->requestor->id); + $e->create_action_hold_notification($note) or return $e->die_event; + $e->commit; + return $note->id; + } + + __PACKAGE__->register_method( + method => 'create_hold_note', + api_name => 'open-ils.circ.hold_note.create', + signature => q/ + Creates a new hold request note object + @param authtoken The login session key + @param note The hold note object to create + @return ID of the new object on success, Event on error + / + ); + + sub create_hold_note { + my( $self, $conn, $auth, $note ) = @_; + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $hold = $e->retrieve_action_hold_request($note->hold) + or return $e->die_event; + my $patron = $e->retrieve_actor_user($hold->usr) + or return $e->die_event; + + return $e->die_event unless + $e->allowed('UPDATE_HOLD', $patron->home_ou); # FIXME: Using permcrud perm listed in fm_IDL.xml for ahrn. Probably want something more specific + + $e->create_action_hold_request_note($note) or return $e->die_event; + $e->commit; + return $note->id; + } + + __PACKAGE__->register_method( + method => 'reset_hold', + api_name => 'open-ils.circ.hold.reset', + signature => q/ + Un-captures and un-targets a hold, essentially returning + it to the state it was in directly after it was placed, + then attempts to re-target the hold + @param authtoken The login session key + @param holdid The id of the hold + / + ); + + + sub reset_hold { + my( $self, $conn, $auth, $holdid ) = @_; + my $reqr; + my ($hold, $evt) = $U->fetch_hold($holdid); + return $evt if $evt; + ($reqr, $evt) = $U->checksesperm($auth, 'UPDATE_HOLD'); + return $evt if $evt; + $evt = _reset_hold($self, $reqr, $hold); + return $evt if $evt; + return 1; + } + + + __PACKAGE__->register_method( + method => 'reset_hold_batch', + api_name => 'open-ils.circ.hold.reset.batch' + ); + + sub reset_hold_batch { + my($self, $conn, $auth, $hold_ids) = @_; + + my $e = new_editor(authtoken => $auth); + return $e->event unless $e->checkauth; + + for my $hold_id ($hold_ids) { + + my $hold = $e->retrieve_action_hold_request( + [$hold_id, {flesh => 1, flesh_fields => {ahr => ['usr']}}]) + or return $e->event; + + next unless $e->allowed('UPDATE_HOLD', $hold->usr->home_ou); + _reset_hold($self, $e->requestor, $hold); + } + + return 1; + } + + + sub _reset_hold { + my ($self, $reqr, $hold) = @_; + + my $e = new_editor(xact =>1, requestor => $reqr); + + $logger->info("reseting hold ".$hold->id); + + my $hid = $hold->id; + + if( $hold->capture_time and $hold->current_copy ) { + + my $copy = $e->retrieve_asset_copy($hold->current_copy) + or return $e->die_event; + + if( $copy->status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) { + $logger->info("setting copy to status 'reshelving' on hold retarget"); + $copy->status(OILS_COPY_STATUS_RESHELVING); + $copy->editor($e->requestor->id); + $copy->edit_date('now'); + $e->update_asset_copy($copy) or return $e->die_event; + + } elsif( $copy->status == OILS_COPY_STATUS_IN_TRANSIT ) { + + # We don't want the copy to remain "in transit" + $copy->status(OILS_COPY_STATUS_RESHELVING); + $logger->warn("! reseting hold [$hid] that is in transit"); + my $transid = $e->search_action_hold_transit_copy({hold=>$hold->id},{idlist=>1})->[0]; + + if( $transid ) { + my $trans = $e->retrieve_action_transit_copy($transid); + if( $trans ) { + $logger->info("Aborting transit [$transid] on hold [$hid] reset..."); + my $evt = OpenILS::Application::Circ::Transit::__abort_transit($e, $trans, $copy, 1); + $logger->info("Transit abort completed with result $evt"); + unless ("$evt" eq 1) { + $e->rollback; + return $evt; + } + } + } + } + } + + $hold->clear_capture_time; + $hold->clear_current_copy; + $hold->clear_shelf_time; + $hold->clear_shelf_expire_time; + + $e->update_action_hold_request($hold) or return $e->die_event; + $e->commit; + + $U->storagereq( + 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id ); + + return undef; + } + + + __PACKAGE__->register_method( + method => 'fetch_open_title_holds', + api_name => 'open-ils.circ.open_holds.retrieve', + signature => q/ + Returns a list ids of un-fulfilled holds for a given title id + @param authtoken The login session key + @param id the id of the item whose holds we want to retrieve + @param type The hold type - M, T, I, V, C, F, R + / + ); + + sub fetch_open_title_holds { + my( $self, $conn, $auth, $id, $type, $org ) = @_; + my $e = new_editor( authtoken => $auth ); + return $e->event unless $e->checkauth; + + $type ||= "T"; + $org ||= $e->requestor->ws_ou; + + # return $e->search_action_hold_request( + # { target => $id, hold_type => $type, fulfillment_time => undef }, {idlist=>1}); + + # XXX make me return IDs in the future ^-- + my $holds = $e->search_action_hold_request( + { + target => $id, + cancel_time => undef, + hold_type => $type, + fulfillment_time => undef + } + ); + + flesh_hold_transits($holds); + return $holds; + } + + + sub flesh_hold_transits { + my $holds = shift; + for my $hold ( @$holds ) { + $hold->transit( + $apputils->simplereq( + 'open-ils.cstore', + "open-ils.cstore.direct.action.hold_transit_copy.search.atomic", + { hold => $hold->id }, + { order_by => { ahtc => 'id desc' }, limit => 1 } + )->[0] + ); + } + } + + sub flesh_hold_notices { + my( $holds, $e ) = @_; + $e ||= new_editor(); + + for my $hold (@$holds) { + my $notices = $e->search_action_hold_notification( + [ + { hold => $hold->id }, + { order_by => { anh => 'notify_time desc' } }, + ], + {idlist=>1} + ); + + $hold->notify_count(scalar(@$notices)); + if( @$notices ) { + my $n = $e->retrieve_action_hold_notification($$notices[0]) + or return $e->event; + $hold->notify_time($n->notify_time); + } + } + } + + + __PACKAGE__->register_method( + method => 'fetch_captured_holds', + api_name => 'open-ils.circ.captured_holds.on_shelf.retrieve', + stream => 1, + signature => q/ + Returns a list of un-fulfilled holds (on the Holds Shelf) for a given title id + @param authtoken The login session key + @param org The org id of the location in question + / + ); + + __PACKAGE__->register_method( + method => 'fetch_captured_holds', + api_name => 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve', + stream => 1, + signature => q/ + Returns list ids of un-fulfilled holds (on the Holds Shelf) for a given title id + @param authtoken The login session key + @param org The org id of the location in question + / + ); + + __PACKAGE__->register_method( + method => 'fetch_captured_holds', + api_name => 'open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve', + stream => 1, + signature => q/ + Returns list ids of shelf-expired un-fulfilled holds for a given title id + @param authtoken The login session key + @param org The org id of the location in question + / + ); + + + sub fetch_captured_holds { + my( $self, $conn, $auth, $org ) = @_; + + my $e = new_editor(authtoken => $auth); + return $e->die_event unless $e->checkauth; + return $e->die_event unless $e->allowed('VIEW_HOLD'); # XXX rely on editor perm + + $org ||= $e->requestor->ws_ou; + + my $query = { + select => { ahr => ['id'] }, + from => { + ahr => { + acp => { + field => 'id', + fkey => 'current_copy' + }, + } + }, + where => { + '+acp' => { status => OILS_COPY_STATUS_ON_HOLDS_SHELF }, + '+ahr' => { + capture_time => { "!=" => undef }, + current_copy => { "!=" => undef }, + fulfillment_time => undef, + pickup_lib => $org, + cancel_time => undef, + } + } + }; + if($self->api_name =~ /expired/) { + $query->{'where'}->{'+ahr'}->{'shelf_expire_time'} = {'<' => 'now'}; + $query->{'where'}->{'+ahr'}->{'shelf_time'} = {'!=' => undef}; + } + my $hold_ids = $e->json_query( $query ); + + for my $hold_id (@$hold_ids) { + if($self->api_name =~ /id_list/) { + $conn->respond($hold_id->{id}); + next; + } else { + $conn->respond( + $e->retrieve_action_hold_request([ + $hold_id->{id}, + { + flesh => 1, + flesh_fields => {ahr => ['notifications', 'transit', 'notes']}, + order_by => {anh => 'notify_time desc'} + } + ]) + ); + } + } + + return undef; + } + + __PACKAGE__->register_method( + method => "print_expired_holds_stream", + api_name => "open-ils.circ.captured_holds.expired.print.stream", + stream => 1 + ); + + sub print_expired_holds_stream { + my ($self, $client, $auth, $params) = @_; + + # No need to check specific permissions: we're going to call another method + # that will do that. + my $e = new_editor("authtoken" => $auth); + return $e->die_event unless $e->checkauth; + + delete($$params{org_id}) unless (int($$params{org_id})); + delete($$params{limit}) unless (int($$params{limit})); + delete($$params{offset}) unless (int($$params{offset})); + delete($$params{chunk_size}) unless (int($$params{chunk_size})); + delete($$params{chunk_size}) if ($$params{chunk_size} && $$params{chunk_size} > 50); # keep the size reasonable + $$params{chunk_size} ||= 10; + + $$params{org_id} = (defined $$params{org_id}) ? $$params{org_id}: $e->requestor->ws_ou; + + my @hold_ids = $self->method_lookup( + "open-ils.circ.captured_holds.id_list.expired_on_shelf.retrieve" + )->run($auth, $params->{"org_id"}); + + if (!@hold_ids) { + $e->disconnect; + return; + } elsif (defined $U->event_code($hold_ids[0])) { + $e->disconnect; + return $hold_ids[0]; + } + + $logger->info("about to stream back up to " . scalar(@hold_ids) . " expired holds"); + + while (@hold_ids) { + my @hid_chunk = splice @hold_ids, 0, $params->{"chunk_size"}; + + my $result_chunk = $e->json_query({ + "select" => { + "acp" => ["barcode"], + "au" => [qw/ + first_given_name second_given_name family_name alias + /], + "acn" => ["label"], + "bre" => ["marc"], + "acpl" => ["name"] + }, + "from" => { + "ahr" => { + "acp" => { + "field" => "id", "fkey" => "current_copy", + "join" => { + "acn" => { + "field" => "id", "fkey" => "call_number", + "join" => { + "bre" => { + "field" => "id", "fkey" => "record" + } + } + }, + "acpl" => {"field" => "id", "fkey" => "location"} + } + }, + "au" => {"field" => "id", "fkey" => "usr"} + } + }, + "where" => {"+ahr" => {"id" => \@hid_chunk}} + }) or return $e->die_event; + $client->respond($result_chunk); + } + + $e->disconnect; + undef; + } + + __PACKAGE__->register_method( + method => "check_title_hold_batch", + api_name => "open-ils.circ.title_hold.is_possible.batch", + stream => 1, + signature => { + desc => '@see open-ils.circ.title_hold.is_possible.batch', + params => [ + { desc => 'Authentication token', type => 'string'}, + { desc => 'Array of Hash of named parameters', type => 'array'}, + ], + return => { + desc => 'Array of response objects', + type => 'array' + } + } + ); + + sub check_title_hold_batch { + my($self, $client, $authtoken, $param_list) = @_; + foreach (@$param_list) { + my ($res) = $self->method_lookup('open-ils.circ.title_hold.is_possible')->run($authtoken, $_); + $client->respond($res); + } + return undef; + } + + + __PACKAGE__->register_method( + method => "check_title_hold", + api_name => "open-ils.circ.title_hold.is_possible", + signature => { + desc => 'Determines if a hold were to be placed by a given user, ' . + 'whether or not said hold would have any potential copies to fulfill it.' . + 'The named paramaters of the second argument include: ' . + 'patronid, titleid, volume_id, copy_id, mrid, depth, pickup_lib, hold_type, selection_ou. ' . + 'See perldoc ' . __PACKAGE__ . ' for more info on these fields.' , + params => [ + { desc => 'Authentication token', type => 'string'}, + { desc => 'Hash of named parameters', type => 'object'}, + ], + return => { + desc => 'List of new message IDs (empty if none)', + type => 'array' + } + } + ); + + =head3 check_title_hold (token, hash) + + The named fields in the hash are: + + patronid - ID of the hold recipient (required) + depth - hold range depth (default 0) + pickup_lib - destination for hold, fallback value for selection_ou + selection_ou - ID of org_unit establishing hard and soft hold boundary settings + issuanceid - ID of the issuance to be held, required for Issuance level hold + titleid - ID (BRN) of the title to be held, required for Title level hold + volume_id - required for Volume level hold + copy_id - required for Copy level hold + mrid - required for Meta-record level hold + hold_type - T, C (or R or F), I, V or M for Title, Copy, Issuance, Volume or Meta-record (default "T") + + All key/value pairs are passed on to do_possibility_checks. + + =cut + + # FIXME: better params checking. what other params are required, if any? + # FIXME: 3 copies of values confusing: $x, $params->{x} and $params{x} + # FIXME: for example, $depth gets a default value, but then $$params{depth} is still + # used in conditionals, where it may be undefined, causing a warning. + # FIXME: specify proper usage/interaction of selection_ou and pickup_lib + + sub check_title_hold { + my( $self, $client, $authtoken, $params ) = @_; + my $e = new_editor(authtoken=>$authtoken); + return $e->event unless $e->checkauth; + + my %params = %$params; + my $depth = $params{depth} || 0; + my $selection_ou = $params{selection_ou} || $params{pickup_lib}; + + my $patron = $e->retrieve_actor_user($params{patronid}) + or return $e->event; + + if( $e->requestor->id ne $patron->id ) { + return $e->event unless + $e->allowed('VIEW_HOLD_PERMIT', $patron->home_ou); + } + + return OpenILS::Event->new('PATRON_BARRED') if $U->is_true($patron->barred); + + my $request_lib = $e->retrieve_actor_org_unit($e->requestor->ws_ou) + or return $e->event; + + my $soft_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_SOFT_BOUNDARY); + my $hard_boundary = $U->ou_ancestor_setting_value($selection_ou, OILS_SETTING_HOLD_HARD_BOUNDARY); + + my @status = (); + my $return_depth = $hard_boundary; # default depth to return on success + if(defined $soft_boundary and $depth < $soft_boundary) { + # work up the tree and as soon as we find a potential copy, use that depth + # also, make sure we don't go past the hard boundary if it exists + + # our min boundary is the greater of user-specified boundary or hard boundary + my $min_depth = (defined $hard_boundary and $hard_boundary > $depth) ? + $hard_boundary : $depth; + + my $depth = $soft_boundary; + while($depth >= $min_depth) { + $logger->info("performing hold possibility check with soft boundary $depth"); + @status = do_possibility_checks($e, $patron, $request_lib, $depth, %params); + if ($status[0]) { + $return_depth = $depth; + last; + } + $depth--; + } + } elsif(defined $hard_boundary and $depth < $hard_boundary) { + # there is no soft boundary, enforce the hard boundary if it exists + $logger->info("performing hold possibility check with hard boundary $hard_boundary"); + @status = do_possibility_checks($e, $patron, $request_lib, $hard_boundary, %params); + } else { + # no boundaries defined, fall back to user specifed boundary or no boundary + $logger->info("performing hold possibility check with no boundary"); + @status = do_possibility_checks($e, $patron, $request_lib, $params{depth}, %params); + } + + if ($status[0]) { + return { + "success" => 1, + "depth" => $return_depth, + "local_avail" => $status[1] + }; + } elsif ($status[2]) { + my $n = scalar @{$status[2]}; + return {"success" => 0, "last_event" => $status[2]->[$n - 1]}; + } else { + return {"success" => 0}; + } + } + + + + sub do_possibility_checks { + my($e, $patron, $request_lib, $depth, %params) = @_; + + my $issuanceid = $params{issuanceid} || ""; + my $titleid = $params{titleid} || ""; + my $volid = $params{volume_id}; + my $copyid = $params{copy_id}; + my $mrid = $params{mrid} || ""; + my $pickup_lib = $params{pickup_lib}; + my $hold_type = $params{hold_type} || 'T'; + my $selection_ou = $params{selection_ou} || $pickup_lib; + + + my $copy; + my $volume; + my $title; + + if( $hold_type eq OILS_HOLD_TYPE_FORCE || $hold_type eq OILS_HOLD_TYPE_RECALL || $hold_type eq OILS_HOLD_TYPE_COPY ) { + + return $e->event unless $copy = $e->retrieve_asset_copy($copyid); + return $e->event unless $volume = $e->retrieve_asset_call_number($copy->call_number); + return $e->event unless $title = $e->retrieve_biblio_record_entry($volume->record); + + return verify_copy_for_hold( + $patron, $e->requestor, $title, $copy, $pickup_lib, $request_lib + ); + + } elsif( $hold_type eq OILS_HOLD_TYPE_VOLUME ) { + + return $e->event unless $volume = $e->retrieve_asset_call_number($volid); + return $e->event unless $title = $e->retrieve_biblio_record_entry($volume->record); + + return _check_volume_hold_is_possible( + $volume, $title, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou + ); + + } elsif( $hold_type eq OILS_HOLD_TYPE_TITLE ) { + + return _check_title_hold_is_possible( + $titleid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou + ); + + } elsif( $hold_type eq OILS_HOLD_TYPE_ISSUANCE ) { + + return _check_issuance_hold_is_possible( + $issuanceid, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou + ); + + } elsif( $hold_type eq OILS_HOLD_TYPE_METARECORD ) { + + my $maps = $e->search_metabib_metarecord_source_map({metarecord=>$mrid}); + my @recs = map { $_->source } @$maps; + my @status = (); + for my $rec (@recs) { + @status = _check_title_hold_is_possible( + $rec, $depth, $request_lib, $patron, $e->requestor, $pickup_lib, $selection_ou + ); + last if $status[1]; + } + return @status; + } + # else { Unrecognized hold_type ! } # FIXME: return error? or 0? + } + + my %prox_cache; + sub create_ranged_org_filter { + my($e, $selection_ou, $depth) = @_; + + # find the orgs from which this hold may be fulfilled, + # based on the selection_ou and depth + + my $top_org = $e->search_actor_org_unit([ + {parent_ou => undef}, + {flesh=>1, flesh_fields=>{aou=>['ou_type']}}])->[0]; + my %org_filter; + + return () if $depth == $top_org->ou_type->depth; + + my $org_list = $U->storagereq('open-ils.storage.actor.org_unit.descendants.atomic', $selection_ou, $depth); + %org_filter = (circ_lib => []); + push(@{$org_filter{circ_lib}}, $_->id) for @$org_list; + + $logger->info("hold org filter at depth $depth and selection_ou ". + "$selection_ou created list of @{$org_filter{circ_lib}}"); + + return %org_filter; + } + + + sub _check_title_hold_is_possible { + my( $titleid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou ) = @_; + + my $e = new_editor(); + my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth); + + # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record + my $copies = $e->json_query( + { + select => { acp => ['id', 'circ_lib'] }, + from => { + acp => { + acn => { + field => 'id', + fkey => 'call_number', + 'join' => { + bre => { + field => 'id', + filter => { id => $titleid }, + fkey => 'record' + } + } + }, + acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' }, + ccs => { field => 'id', filter => { holdable => 't'}, fkey => 'status' } + } + }, + where => { + '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter } + } + } + ); + + $logger->info("title possible found ".scalar(@$copies)." potential copies"); + return ( + 0, 0, [ + new OpenILS::Event( + "HIGH_LEVEL_HOLD_HAS_NO_COPIES", + "payload" => {"fail_part" => "no_ultimate_items"} + ) + ] + ) unless @$copies; + + # ----------------------------------------------------------------------- + # sort the copies into buckets based on their circ_lib proximity to + # the patron's home_ou. + # ----------------------------------------------------------------------- + + my $home_org = $patron->home_ou; + my $req_org = $request_lib->id; + + $logger->info("prox cache $home_org " . $prox_cache{$home_org}); + + $prox_cache{$home_org} = + $e->search_actor_org_unit_proximity({from_org => $home_org}) + unless $prox_cache{$home_org}; + my $home_prox = $prox_cache{$home_org}; + + my %buckets; + my %hash = map { ($_->to_org => $_->prox) } @$home_prox; + push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies; + + my @keys = sort { $a <=> $b } keys %buckets; + + + if( $home_org ne $req_org ) { + # ----------------------------------------------------------------------- + # shove the copies close to the request_lib into the primary buckets + # directly before the farthest away copies. That way, they are not + # given priority, but they are checked before the farthest copies. + # ----------------------------------------------------------------------- + $prox_cache{$req_org} = + $e->search_actor_org_unit_proximity({from_org => $req_org}) + unless $prox_cache{$req_org}; + my $req_prox = $prox_cache{$req_org}; + + my %buckets2; + my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox; + push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies; + + my $highest_key = $keys[@keys - 1]; # the farthest prox in the exising buckets + my $new_key = $highest_key - 0.5; # right before the farthest prox + my @keys2 = sort { $a <=> $b } keys %buckets2; + for my $key (@keys2) { + last if $key >= $highest_key; + push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}}; + } + } + + @keys = sort { $a <=> $b } keys %buckets; + + my $title; + my %seen; + my @status; + OUTER: for my $key (@keys) { + my @cps = @{$buckets{$key}}; + + $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key"); + + for my $copyid (@cps) { + + next if $seen{$copyid}; + $seen{$copyid} = 1; # there could be dupes given the merged buckets + my $copy = $e->retrieve_asset_copy($copyid); + $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib); + + unless($title) { # grab the title if we don't already have it + my $vol = $e->retrieve_asset_call_number( + [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] ); + $title = $vol->record; + } + + @status = verify_copy_for_hold( + $patron, $requestor, $title, $copy, $pickup_lib, $request_lib); + + last OUTER if $status[0]; + } + } + + return @status; + } + + sub _check_issuance_hold_is_possible { + my( $issuanceid, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou ) = @_; + + my $e = new_editor(); + my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth); + + # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record + my $copies = $e->json_query( + { + select => { acp => ['id', 'circ_lib'] }, + from => { + acp => { + sitem => { + field => 'unit', + fkey => 'id', + filter => { issuance => $issuanceid } + }, + acpl => { field => 'id', filter => { holdable => 't'}, fkey => 'location' }, + ccs => { field => 'id', filter => { holdable => 't'}, fkey => 'status' } + } + }, + where => { + '+acp' => { circulate => 't', deleted => 'f', holdable => 't', %org_filter } + }, + distinct => 1 + } + ); + + $logger->info("issuance possible found ".scalar(@$copies)." potential copies"); + + my $empty_ok; + if (!@$copies) { + $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok'); + $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled)); + + return ( + 0, 0, [ + new OpenILS::Event( + "HIGH_LEVEL_HOLD_HAS_NO_COPIES", + "payload" => {"fail_part" => "no_ultimate_items"} + ) + ] + ) unless $empty_ok; + + return (1, 0); + } + + # ----------------------------------------------------------------------- + # sort the copies into buckets based on their circ_lib proximity to + # the patron's home_ou. + # ----------------------------------------------------------------------- + + my $home_org = $patron->home_ou; + my $req_org = $request_lib->id; + + $logger->info("prox cache $home_org " . $prox_cache{$home_org}); + + $prox_cache{$home_org} = + $e->search_actor_org_unit_proximity({from_org => $home_org}) + unless $prox_cache{$home_org}; + my $home_prox = $prox_cache{$home_org}; + + my %buckets; + my %hash = map { ($_->to_org => $_->prox) } @$home_prox; + push( @{$buckets{ $hash{$_->{circ_lib}} } }, $_->{id} ) for @$copies; + + my @keys = sort { $a <=> $b } keys %buckets; + + + if( $home_org ne $req_org ) { + # ----------------------------------------------------------------------- + # shove the copies close to the request_lib into the primary buckets + # directly before the farthest away copies. That way, they are not + # given priority, but they are checked before the farthest copies. + # ----------------------------------------------------------------------- + $prox_cache{$req_org} = + $e->search_actor_org_unit_proximity({from_org => $req_org}) + unless $prox_cache{$req_org}; + my $req_prox = $prox_cache{$req_org}; + + my %buckets2; + my %hash2 = map { ($_->to_org => $_->prox) } @$req_prox; + push( @{$buckets2{ $hash2{$_->{circ_lib}} } }, $_->{id} ) for @$copies; + + my $highest_key = $keys[@keys - 1]; # the farthest prox in the exising buckets + my $new_key = $highest_key - 0.5; # right before the farthest prox + my @keys2 = sort { $a <=> $b } keys %buckets2; + for my $key (@keys2) { + last if $key >= $highest_key; + push( @{$buckets{$new_key}}, $_ ) for @{$buckets2{$key}}; + } + } + + @keys = sort { $a <=> $b } keys %buckets; + + my $title; + my %seen; + my @status; + OUTER: for my $key (@keys) { + my @cps = @{$buckets{$key}}; + + $logger->info("looking at " . scalar(@{$buckets{$key}}). " copies in proximity bucket $key"); + + for my $copyid (@cps) { + + next if $seen{$copyid}; + $seen{$copyid} = 1; # there could be dupes given the merged buckets + my $copy = $e->retrieve_asset_copy($copyid); + $logger->debug("looking at bucket_key=$key, copy $copyid : circ_lib = " . $copy->circ_lib); + + unless($title) { # grab the title if we don't already have it + my $vol = $e->retrieve_asset_call_number( + [ $copy->call_number, { flesh => 1, flesh_fields => { bre => ['fixed_fields'], acn => ['record'] } } ] ); + $title = $vol->record; + } + + @status = verify_copy_for_hold( + $patron, $requestor, $title, $copy, $pickup_lib, $request_lib); + + last OUTER if $status[0]; + } + } + + if (!$status[0]) { + if (!defined($empty_ok)) { + $empty_ok = $e->retrieve_config_global_flag('circ.holds.empty_issuance_ok'); + $empty_ok = ($empty_ok and $U->is_true($empty_ok->enabled)); + } + + return (1,0) if ($empty_ok); + } + return @status; + } + + + sub _check_volume_hold_is_possible { + my( $vol, $title, $depth, $request_lib, $patron, $requestor, $pickup_lib, $selection_ou ) = @_; + my %org_filter = create_ranged_org_filter(new_editor(), $selection_ou, $depth); + my $copies = new_editor->search_asset_copy({call_number => $vol->id, %org_filter}); + $logger->info("checking possibility of volume hold for volume ".$vol->id); + + return ( + 0, 0, [ + new OpenILS::Event( + "HIGH_LEVEL_HOLD_HAS_NO_COPIES", + "payload" => {"fail_part" => "no_ultimate_items"} + ) + ] + ) unless @$copies; + + my @status; + for my $copy ( @$copies ) { + @status = verify_copy_for_hold( + $patron, $requestor, $title, $copy, $pickup_lib, $request_lib ); + last if $status[0]; + } + return @status; + } + + + + sub verify_copy_for_hold { + my( $patron, $requestor, $title, $copy, $pickup_lib, $request_lib ) = @_; + $logger->info("checking possibility of copy in hold request for copy ".$copy->id); + my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold( + { patron => $patron, + requestor => $requestor, + copy => $copy, + title => $title, + title_descriptor => $title->fixed_fields, # this is fleshed into the title object + pickup_lib => $pickup_lib, + request_lib => $request_lib, + new_hold => 1, + show_event_list => 1 + } + ); + + return ( + (not scalar @$permitted), # true if permitted is an empty arrayref + ( + ($copy->circ_lib == $pickup_lib) and + ($copy->status == OILS_COPY_STATUS_AVAILABLE) + ), + $permitted + ); + } + + + + sub find_nearest_permitted_hold { + + my $class = shift; + my $editor = shift; # CStoreEditor object + my $copy = shift; # copy to target + my $user = shift; # staff + my $check_only = shift; # do no updates, just see if the copy could fulfill a hold + + my $evt = OpenILS::Event->new('ACTION_HOLD_REQUEST_NOT_FOUND'); + + my $bc = $copy->barcode; + + # find any existing holds that already target this copy + my $old_holds = $editor->search_action_hold_request( + { current_copy => $copy->id, + cancel_time => undef, + capture_time => undef + } + ); + + # hold->type "R" means we need this copy + for my $h (@$old_holds) { return ($h) if $h->hold_type eq 'R'; } + + + my $hold_stall_interval = $U->ou_ancestor_setting_value($user->ws_ou, OILS_SETTING_HOLD_SOFT_STALL); + + $logger->info("circulator: searching for best hold at org ".$user->ws_ou. + " and copy $bc with a hold stalling interval of ". ($hold_stall_interval || "(none)")); + + my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo'); + + # search for what should be the best holds for this copy to fulfill + my $best_holds = $U->storagereq( + "open-ils.storage.action.hold_request.nearest_hold.atomic", + $user->ws_ou, $copy->id, 10, $hold_stall_interval, $fifo ); + + unless(@$best_holds) { + + if( my $hold = $$old_holds[0] ) { + $logger->info("circulator: using existing pre-targeted hold ".$hold->id." in hold search"); + return ($hold); + } + + $logger->info("circulator: no suitable holds found for copy $bc"); + return (undef, $evt); + } + + + my $best_hold; + + # for each potential hold, we have to run the permit script + # to make sure the hold is actually permitted. + my %reqr_cache; + my %org_cache; + for my $holdid (@$best_holds) { + next unless $holdid; + $logger->info("circulator: checking if hold $holdid is permitted for copy $bc"); + + my $hold = $editor->retrieve_action_hold_request($holdid) or next; + my $reqr = $reqr_cache{$hold->requestor} || $editor->retrieve_actor_user($hold->requestor); + my $rlib = $org_cache{$hold->request_lib} || $editor->retrieve_actor_org_unit($hold->request_lib); + + $reqr_cache{$hold->requestor} = $reqr; + $org_cache{$hold->request_lib} = $rlib; + + # see if this hold is permitted + my $permitted = OpenILS::Utils::PermitHold::permit_copy_hold( + { patron_id => $hold->usr, + requestor => $reqr, + copy => $copy, + pickup_lib => $hold->pickup_lib, + request_lib => $rlib, + retarget => 1 + } + ); + + if( $permitted ) { + $best_hold = $hold; + last; + } + } + + + unless( $best_hold ) { # no "good" permitted holds were found + if( my $hold = $$old_holds[0] ) { # can we return a pre-targeted hold? + $logger->info("circulator: using existing pre-targeted hold ".$hold->id." in hold search"); + return ($hold); + } + + # we got nuthin + $logger->info("circulator: no suitable holds found for copy $bc"); + return (undef, $evt); + } + + $logger->info("circulator: best hold ".$best_hold->id." found for copy $bc"); + + # indicate a permitted hold was found + return $best_hold if $check_only; + + # we've found a permitted hold. we need to "grab" the copy + # to prevent re-targeted holds (next part) from re-grabbing the copy + $best_hold->current_copy($copy->id); + $editor->update_action_hold_request($best_hold) + or return (undef, $editor->event); + + + my @retarget; + + # re-target any other holds that already target this copy + for my $old_hold (@$old_holds) { + next if $old_hold->id eq $best_hold->id; # don't re-target the hold we want + $logger->info("circulator: clearing current_copy and prev_check_time on hold ". + $old_hold->id." after a better hold [".$best_hold->id."] was found"); + $old_hold->clear_current_copy; + $old_hold->clear_prev_check_time; + $editor->update_action_hold_request($old_hold) + or return (undef, $editor->event); + push(@retarget, $old_hold->id); + } + + return ($best_hold, undef, (@retarget) ? \@retarget : undef); + } + + + + + + + __PACKAGE__->register_method( + method => 'all_rec_holds', + api_name => 'open-ils.circ.holds.retrieve_all_from_title', + ); + + sub all_rec_holds { + my( $self, $conn, $auth, $title_id, $args ) = @_; + + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; + $e->allowed('VIEW_HOLD') or return $e->event; + + $args ||= {}; + $args->{fulfillment_time} = undef; # we don't want to see old fulfilled holds + $args->{cancel_time} = undef; + + my $resp = { volume_holds => [], copy_holds => [], metarecord_holds => [] }; + + my $mr_map = $e->search_metabib_metarecord_source_map({source => $title_id})->[0]; + if($mr_map) { + $resp->{metarecord_holds} = $e->search_action_hold_request( + { hold_type => OILS_HOLD_TYPE_METARECORD, + target => $mr_map->metarecord, + %$args + }, {idlist => 1} + ); + } + + $resp->{title_holds} = $e->search_action_hold_request( + { + hold_type => OILS_HOLD_TYPE_TITLE, + target => $title_id, + %$args + }, {idlist=>1} ); + + my $vols = $e->search_asset_call_number( + { record => $title_id, deleted => 'f' }, {idlist=>1}); + + return $resp unless @$vols; + + $resp->{volume_holds} = $e->search_action_hold_request( + { + hold_type => OILS_HOLD_TYPE_VOLUME, + target => $vols, + %$args }, + {idlist=>1} ); + + my $copies = $e->search_asset_copy( + { call_number => $vols, deleted => 'f' }, {idlist=>1}); + + return $resp unless @$copies; + + $resp->{copy_holds} = $e->search_action_hold_request( + { + hold_type => OILS_HOLD_TYPE_COPY, + target => $copies, + %$args }, + {idlist=>1} ); + + return $resp; + } + + + + + + __PACKAGE__->register_method( + method => 'uber_hold', + authoritative => 1, + api_name => 'open-ils.circ.hold.details.retrieve' + ); + + sub uber_hold { - my($self, $client, $auth, $hold_id) = @_; ++ my($self, $client, $auth, $hold_id, $args) = @_; + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; - return uber_hold_impl($e, $hold_id); ++ return uber_hold_impl($e, $hold_id, $args); + } + + __PACKAGE__->register_method( + method => 'batch_uber_hold', + authoritative => 1, + stream => 1, + api_name => 'open-ils.circ.hold.details.batch.retrieve' + ); + + sub batch_uber_hold { - my($self, $client, $auth, $hold_ids) = @_; ++ my($self, $client, $auth, $hold_ids, $args) = @_; + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; - $client->respond(uber_hold_impl($e, $_)) for @$hold_ids; ++ $client->respond(uber_hold_impl($e, $_, $args)) for @$hold_ids; + return undef; + } + + sub uber_hold_impl { - my($e, $hold_id) = @_; ++ my($e, $hold_id, $args) = @_; + + my $resp = {}; ++ $args ||= {}; + + my $hold = $e->retrieve_action_hold_request( + [ + $hold_id, + { + flesh => 1, + flesh_fields => { ahr => [ 'current_copy', 'usr', 'notes' ] } + } + ] + ) or return $e->event; + + if($hold->usr->id ne $e->requestor->id) { + # A user is allowed to see his/her own holds + $e->allowed('VIEW_HOLD') or return $e->event; + $hold->notes( # filter out any non-staff ("private") notes + [ grep { !$U->is_true($_->staff) } @{$hold->notes} ] ); + + } else { + # caller is asking for own hold, but may not have permission to view staff notes + unless($e->allowed('VIEW_HOLD')) { + $hold->notes( # filter out any staff notes + [ grep { $U->is_true($_->staff) } @{$hold->notes} ] ); + } + } + + my $user = $hold->usr; + $hold->usr($user->id); + - my $card = $e->retrieve_actor_card($user->card) - or return $e->event; + - my( $mvr, $volume, $copy ) = find_hold_mvr($e, $hold); ++ my( $mvr, $volume, $copy, $issuance, $bre ) = find_hold_mvr($e, $hold, $args->{suppress_mvr}); + - flesh_hold_notices([$hold], $e); - flesh_hold_transits([$hold]); ++ flesh_hold_notices([$hold], $e) unless $args->{suppress_notices}; ++ flesh_hold_transits([$hold]) unless $args->{suppress_transits}; + + my $details = retrieve_hold_queue_status_impl($e, $hold); + - return { ++ my $resp = { + hold => $hold, + copy => $copy, + volume => $volume, - mvr => $mvr, - patron_first => $user->first_given_name, - patron_last => $user->family_name, - patron_barcode => $card->barcode, - patron_alias => $user->alias, + %$details + }; ++ ++ $resp->{mvr} = $mvr unless $args->{suppress_mvr}; ++ unless($args->{suppress_patron_details}) { ++ my $card = $e->retrieve_actor_card($user->card) or return $e->event; ++ $resp->{patron_first} = $user->first_given_name, ++ $resp->{patron_last} = $user->family_name, ++ $resp->{patron_barcode} = $card->barcode, ++ $resp->{patron_alias} = $user->alias, ++ }; ++ ++ $resp->{bre} = $bre if $args->{include_bre}; ++ ++ return $resp; + } + + + + # ----------------------------------------------------- + # Returns the MVR object that represents what the + # hold is all about + # ----------------------------------------------------- + sub find_hold_mvr { - my( $e, $hold ) = @_; ++ my( $e, $hold, $no_mvr ) = @_; + + my $tid; + my $copy; + my $volume; + my $issuance; + + if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) { + my $mr = $e->retrieve_metabib_metarecord($hold->target) + or return $e->event; + $tid = $mr->master_record; + + } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) { + $tid = $hold->target; + + } elsif( $hold->hold_type eq OILS_HOLD_TYPE_VOLUME ) { + $volume = $e->retrieve_asset_call_number($hold->target) + or return $e->event; + $tid = $volume->record; + + } elsif( $hold->hold_type eq OILS_HOLD_TYPE_ISSUANCE ) { + $issuance = $e->retrieve_serial_issuance([ + $hold->target, + {flesh => 1, flesh_fields => {siss => [ qw/subscription/ ]}} + ]) or return $e->event; + + $tid = $issuance->subscription->record_entry; + + } elsif( $hold->hold_type eq OILS_HOLD_TYPE_COPY ) { + $copy = $e->retrieve_asset_copy([ + $hold->target, + {flesh => 1, flesh_fields => {acp => ['call_number']}} + ]) or return $e->event; + + $volume = $copy->call_number; + $tid = $volume->record; + } + + if(!$copy and ref $hold->current_copy ) { + $copy = $hold->current_copy; + $hold->current_copy($copy->id); + } + + if(!$volume and $copy) { + $volume = $e->retrieve_asset_call_number($copy->call_number); + } + + # TODO return metarcord mvr for M holds + my $title = $e->retrieve_biblio_record_entry($tid); - return ( $U->record_to_mvr($title), $volume, $copy, $issuance ); ++ return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $title ); + } + + __PACKAGE__->register_method( + method => 'clear_shelf_cache', + api_name => 'open-ils.circ.hold.clear_shelf.get_cache', + stream => 1, + signature => { + desc => q/ + Returns the holds processed with the given cache key + / + } + ); + + sub clear_shelf_cache { + my($self, $client, $auth, $cache_key, $chunk_size) = @_; + my $e = new_editor(authtoken => $auth, xact => 1); + return $e->die_event unless $e->checkauth and $e->allowed('VIEW_HOLD'); + + $chunk_size ||= 25; + my $hold_data = OpenSRF::Utils::Cache->new('global')->get_cache($cache_key); + + if (!$hold_data) { + $logger->info("no hold data found in cache"); # XXX TODO return event + $e->rollback; + return undef; + } + + my $maximum = 0; + foreach (keys %$hold_data) { + $maximum += scalar(@{ $hold_data->{$_} }); + } + $client->respond({"maximum" => $maximum, "progress" => 0}); + + for my $action (sort keys %$hold_data) { + while (@{$hold_data->{$action}}) { + my @hid_chunk = splice @{$hold_data->{$action}}, 0, $chunk_size; + + my $result_chunk = $e->json_query({ + "select" => { + "acp" => ["barcode"], + "au" => [qw/ + first_given_name second_given_name family_name alias + /], + "acn" => ["label"], + "bre" => ["marc"], + "acpl" => ["name"], + "ahr" => ["id"] + }, + "from" => { + "ahr" => { + "acp" => { + "field" => "id", "fkey" => "current_copy", + "join" => { + "acn" => { + "field" => "id", "fkey" => "call_number", + "join" => { + "bre" => { + "field" => "id", "fkey" => "record" + } + } + }, + "acpl" => {"field" => "id", "fkey" => "location"} + } + }, + "au" => {"field" => "id", "fkey" => "usr"} + } + }, + "where" => {"+ahr" => {"id" => \@hid_chunk}} + }, {"substream" => 1}) or return $e->die_event; + + $client->respond([ + map { + +{"action" => $action, "hold_details" => $_} + } @$result_chunk + ]); + } + } + + $e->rollback; + return undef; + } + + + __PACKAGE__->register_method( + method => 'clear_shelf_process', + stream => 1, + api_name => 'open-ils.circ.hold.clear_shelf.process', + signature => { + desc => q/ + 1. Find all holds that have expired on the holds shelf + 2. Cancel the holds + 3. If a clear-shelf status is configured, put targeted copies into this status + 4. Divide copies into 3 groups: items to transit, items to reshelve, and items + that are needed for holds. No subsequent action is taken on the holds + or items after grouping. + / + } + ); + + sub clear_shelf_process { + my($self, $client, $auth, $org_id) = @_; + + my $e = new_editor(authtoken=>$auth, xact => 1); + $e->checkauth or return $e->die_event; + my $cache = OpenSRF::Utils::Cache->new('global'); + + $org_id ||= $e->requestor->ws_ou; + $e->allowed('UPDATE_HOLD', $org_id) or return $e->die_event; + + my $copy_status = $U->ou_ancestor_setting_value($org_id, 'circ.holds.clear_shelf.copy_status'); + + # Find holds on the shelf that have been there too long + my $hold_ids = $e->search_action_hold_request( + { shelf_expire_time => {'<' => 'now'}, + pickup_lib => $org_id, + cancel_time => undef, + fulfillment_time => undef, + shelf_time => {'!=' => undef}, + capture_time => {'!=' => undef}, + current_copy => {'!=' => undef}, + }, + { idlist => 1 } + ); + + my @holds; + my $chunk_size = 25; # chunked status updates + my $counter = 0; + for my $hold_id (@$hold_ids) { + + $logger->info("Clear shelf processing hold $hold_id"); + + my $hold = $e->retrieve_action_hold_request([ + $hold_id, { + flesh => 1, + flesh_fields => {ahr => ['current_copy']} + } + ]); + + $hold->cancel_time('now'); + $hold->cancel_cause(2); # Hold Shelf expiration + $e->update_action_hold_request($hold) or return $e->die_event; + + my $copy = $hold->current_copy; + + if($copy_status or $copy_status == 0) { + # if a clear-shelf copy status is defined, update the copy + $copy->status($copy_status); + $copy->edit_date('now'); + $copy->editor($e->requestor->id); + $e->update_asset_copy($copy) or return $e->die_event; + } + + push(@holds, $hold); + $client->respond({maximum => scalar(@holds), progress => $counter}) if ( (++$counter % $chunk_size) == 0); + } + + if ($e->commit) { + + my %cache_data = ( + hold => [], + transit => [], + shelf => [] + ); + + for my $hold (@holds) { + + my $copy = $hold->current_copy; + my ($alt_hold) = __PACKAGE__->find_nearest_permitted_hold($e, $copy, $e->requestor, 1); + + if($alt_hold) { + + push(@{$cache_data{hold}}, $hold->id); # copy is needed for a hold + + } elsif($copy->circ_lib != $e->requestor->ws_ou) { + + push(@{$cache_data{transit}}, $hold->id); # copy needs to transit + + } else { + + push(@{$cache_data{shelf}}, $hold->id); # copy needs to go back to the shelf + } + } + + my $cache_key = md5_hex(time . $$ . rand()); + $logger->info("clear_shelf_cache: storing under $cache_key"); + $cache->put_cache($cache_key, \%cache_data, 7200); # TODO: 2 hours. configurable? + + # tell the client we're done + $client->respond_complete({cache_key => $cache_key}); + + # fire off the hold cancelation trigger and wait for response so don't flood the service + $U->create_events_for_hook( + 'hold_request.cancel.expire_holds_shelf', + $_, $org_id, undef, undef, 1) for @holds; + + } else { + # tell the client we're done + $client->respond_complete; + } + } + + __PACKAGE__->register_method( + method => 'usr_hold_summary', + api_name => 'open-ils.circ.holds.user_summary', + signature => q/ + Returns a summary of holds statuses for a given user + / + ); + + sub usr_hold_summary { + my($self, $conn, $auth, $user_id) = @_; + + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; + $e->allowed('VIEW_HOLD') or return $e->event; + + my $holds = $e->search_action_hold_request( + { + usr => $user_id , + fulfillment_time => undef, + cancel_time => undef, + } + ); + + my %summary = (1 => 0, 2 => 0, 3 => 0, 4 => 0); + $summary{_hold_status($e, $_)} += 1 for @$holds; + return \%summary; + } + + + + __PACKAGE__->register_method( + method => 'hold_has_copy_at', + api_name => 'open-ils.circ.hold.has_copy_at', + signature => { + desc => + 'Returns the ID of the found copy and name of the shelving location if there is ' . + 'an available copy at the specified org unit. Returns empty hash otherwise. ' . + 'The anticipated use for this method is to determine whether an item is ' . + 'available at the library where the user is placing the hold (or, alternatively, '. + 'at the pickup library) to encourage bypassing the hold placement and just ' . + 'checking out the item.' , + params => { + { desc => 'Authentication Token', type => 'string' }, + { desc => 'Method Arguments. Options include: hold_type, hold_target, org_unit. ' + . 'hold_type is the hold type code (T, V, C, M, ...). ' + . 'hold_target is the identifier of the hold target object. ' + . 'org_unit is org unit ID.', + type => 'object' + }, + }, + return => { + desc => q/Result hash like { "copy" : copy_id, "location" : location_name }, empty hash on misses, event on error./, + type => 'object' + } + } + ); + + sub hold_has_copy_at { + my($self, $conn, $auth, $args) = @_; + + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; + + my $hold_type = $$args{hold_type}; + my $hold_target = $$args{hold_target}; + my $org_unit = $$args{org_unit}; + + my $query = { + select => {acp => ['id'], acpl => ['name']}, + from => { + acp => { + acpl => {field => 'id', filter => { holdable => 't'}, fkey => 'location'}, + ccs => {field => 'id', filter => { holdable => 't'}, fkey => 'status' } + } + }, + where => {'+acp' => { circulate => 't', deleted => 'f', holdable => 't', circ_lib => $org_unit}}, + limit => 1 + }; + + if($hold_type eq 'C') { + + $query->{where}->{'+acp'}->{id} = $hold_target; + + } elsif($hold_type eq 'V') { + + $query->{where}->{'+acp'}->{call_number} = $hold_target; + + } elsif($hold_type eq 'T') { + + $query->{from}->{acp}->{acn} = { + field => 'id', + fkey => 'call_number', + 'join' => { + bre => { + field => 'id', + filter => {id => $hold_target}, + fkey => 'record' + } + } + }; + + } else { + + $query->{from}->{acp}->{acn} = { + field => 'id', + fkey => 'call_number', + join => { + bre => { + field => 'id', + fkey => 'record', + join => { + mmrsm => { + field => 'source', + fkey => 'id', + filter => {metarecord => $hold_target}, + } + } + } + } + }; + } + + my $res = $e->json_query($query)->[0] or return {}; + return {copy => $res->{id}, location => $res->{name}} if $res; + } + + + # returns true if the user already has an item checked out + # that could be used to fulfill the requested hold. + sub hold_item_is_checked_out { + my($e, $user_id, $hold_type, $hold_target) = @_; + + my $query = { + select => {acp => ['id']}, + from => {acp => {}}, + where => { + '+acp' => { + id => { + in => { # copies for circs the user has checked out + select => {circ => ['target_copy']}, + from => 'circ', + where => { + usr => $user_id, + checkin_time => undef, + '-or' => [ + {stop_fines => ["MAXFINES","LONGOVERDUE"]}, + {stop_fines => undef} + ], + } + } + } + } + }, + limit => 1 + }; + + if($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') { + + $query->{where}->{'+acp'}->{id}->{in}->{where}->{'target_copy'} = $hold_target; + + } elsif($hold_type eq 'V') { + + $query->{where}->{'+acp'}->{call_number} = $hold_target; + + } elsif($hold_type eq 'I') { + + $query->{from}->{acp}->{sitem} = { + field => 'unit', + fkey => 'id', + filter => {issuance => $hold_target}, + }; + + } elsif($hold_type eq 'T') { + + $query->{from}->{acp}->{acn} = { + field => 'id', + fkey => 'call_number', + 'join' => { + bre => { + field => 'id', + filter => {id => $hold_target}, + fkey => 'record' + } + } + }; + + } else { + + $query->{from}->{acp}->{acn} = { + field => 'id', + fkey => 'call_number', + join => { + bre => { + field => 'id', + fkey => 'record', + join => { + mmrsm => { + field => 'source', + fkey => 'id', + filter => {metarecord => $hold_target}, + } + } + } + } + }; + } + + return $e->json_query($query)->[0]; + } + + __PACKAGE__->register_method( + method => 'change_hold_title', + api_name => 'open-ils.circ.hold.change_title', + signature => { + desc => q/ + Updates all title level holds targeting the specified bibs to point a new bib./, + params => [ + { desc => 'Authentication Token', type => 'string' }, + { desc => 'New Target Bib Id', type => 'number' }, + { desc => 'Old Target Bib Ids', type => 'array' }, + ], + return => { desc => '1 on success' } + } + ); + + sub change_hold_title { + my( $self, $client, $auth, $new_bib_id, $bib_ids ) = @_; + + my $e = new_editor(authtoken=>$auth, xact=>1); + return $e->die_event unless $e->checkauth; + + my $holds = $e->search_action_hold_request( + [ + { + cancel_time => undef, + fulfillment_time => undef, + hold_type => 'T', + target => $bib_ids + }, + { + flesh => 1, + flesh_fields => { ahr => ['usr'] } + } + ], + { substream => 1 } + ); + + for my $hold (@$holds) { + $e->allowed('UPDATE_HOLD', $hold->usr->home_ou) or return $e->die_event; + $logger->info("Changing hold " . $hold->id . " target from " . $hold->target . " to $new_bib_id in title hold target change"); + $hold->target( $new_bib_id ); + $e->update_action_hold_request($hold) or return $e->die_event; + } + + $e->commit; + + return 1; + } + + + __PACKAGE__->register_method( + method => 'rec_hold_count', + api_name => 'open-ils.circ.bre.holds.count', + signature => { + desc => q/Returns the total number of holds that target the + selected bib record or its associated copies and call_numbers/, + params => [ + { desc => 'Bib ID', type => 'number' }, + ], + return => {desc => 'Hold count', type => 'number'} + } + ); + + __PACKAGE__->register_method( + method => 'rec_hold_count', + api_name => 'open-ils.circ.mmr.holds.count', + signature => { + desc => q/Returns the total number of holds that target the + selected metarecord or its associated copies, call_numbers, and bib records/, + params => [ + { desc => 'Metarecord ID', type => 'number' }, + ], + return => {desc => 'Hold count', type => 'number'} + } + ); + + sub rec_hold_count { + my($self, $conn, $target_id) = @_; + + + my $mmr_join = { + mmrsm => { + field => 'id', + fkey => 'source', + filter => {metarecord => $target_id} + } + }; + + my $bre_join = { + bre => { + field => 'id', + filter => { id => $target_id }, + fkey => 'record' + } + }; + + if($self->api_name =~ /mmr/) { + delete $bre_join->{bre}->{filter}; + $bre_join->{bre}->{join} = $mmr_join; + } + + my $cn_join = { + acn => { + field => 'id', + fkey => 'call_number', + join => $bre_join + } + }; + + my $query = { + select => {ahr => [{column => 'id', transform => 'count', alias => 'count'}]}, + from => 'ahr', + where => { + '+ahr' => { + cancel_time => undef, + fulfillment_time => undef, + '-or' => [ + { + '-and' => { + hold_type => 'C', + target => { + in => { + select => {acp => ['id']}, + from => { acp => $cn_join } + } + } + } + }, + { + '-and' => { + hold_type => 'V', + target => { + in => { + select => {acn => ['id']}, + from => {acn => $bre_join} + } + } + } + }, + { + '-and' => { + hold_type => 'T', + target => $target_id + } + } + ] + } + } + }; + + if($self->api_name =~ /mmr/) { + $query->{where}->{'+ahr'}->{'-or'}->[2] = { + '-and' => { + hold_type => 'T', + target => { + in => { + select => {bre => ['id']}, + from => {bre => $mmr_join} + } + } + } + }; + + $query->{where}->{'+ahr'}->{'-or'}->[3] = { + '-and' => { + hold_type => 'M', + target => $target_id + } + }; + } + + + return new_editor()->json_query($query)->[0]->{count}; + } + + + + + + + 1; diff --cc Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm index 0000000000,42eb6ffbf7..e804562fd0 mode 000000,100644..100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm @@@ -1,0 -1,230 +1,272 @@@ + package OpenILS::WWW::EGWeb; + use strict; use warnings; + use Template; + use XML::Simple; + use XML::LibXML; + use File::stat; + use Apache2::Const -compile => qw(OK DECLINED HTTP_INTERNAL_SERVER_ERROR); + use Apache2::Log; + use OpenSRF::EX qw(:try); ++use OpenILS::Utils::CStoreEditor; + + use constant OILS_HTTP_COOKIE_SKIN => 'oils:skin'; + use constant OILS_HTTP_COOKIE_THEME => 'oils:theme'; + use constant OILS_HTTP_COOKIE_LOCALE => 'oils:locale'; + + my $web_config; + my $web_config_file; + my $web_config_edit_time; + + sub import { + my $self = shift; + $web_config_file = shift; + unless(-r $web_config_file) { - warn "Invalid web config $web_config_file"; ++ warn "Invalid web config $web_config_file\n"; + return; + } + check_web_config(); + } + + + sub handler { + my $r = shift; + check_web_config($r); # option to disable this + my $ctx = load_context($r); + my $base = $ctx->{base_path}; ++ ++ $r->content_type('text/html; encoding=utf8'); ++ + my($template, $page_args, $as_xml) = find_template($r, $base, $ctx); ++ $ctx->{page_args} = $page_args; ++ ++ my $stat = run_context_loader($r, $ctx); ++ ++ return $stat unless $stat == Apache2::Const::OK; + return Apache2::Const::DECLINED unless $template; + + $template = $ctx->{skin} . "/$template"; - $ctx->{page_args} = $page_args; - $r->content_type('text/html; encoding=utf8'); + + my $tt = Template->new({ + OUTPUT => ($as_xml) ? sub { parse_as_xml($r, $ctx, @_); } : $r, + INCLUDE_PATH => $ctx->{template_paths}, ++ DEBUG => $ctx->{debug_template} + }); + + unless($tt->process($template, {ctx => $ctx})) { + $r->log->warn('Template error: ' . $tt->error); + return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; + } + + return Apache2::Const::OK; + } + ++ ++sub run_context_loader { ++ my $r = shift; ++ my $ctx = shift; ++ ++ my $stat = Apache2::Const::OK; ++ ++ my $loader = $r->dir_config('OILSWebContextLoader'); ++ return $stat unless $loader; ++ ++ eval { ++ $loader->use; ++ $stat = $loader->new($r, $ctx)->load; ++ }; ++ ++ if($@) { ++ $r->log->error("Context Loader error: $@"); ++ return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; ++ } ++ ++ $r->log->info("context loader resulted in status $stat"); ++ return $stat; ++} ++ + sub parse_as_xml { + my $r = shift; + my $ctx = shift; + my $data = shift; + + my $success = 0; + + try { + my $doc = XML::LibXML->new->parse_string($data); + $data = $doc->documentElement->toStringC14N; + $data = $ctx->{final_dtd} . "\n" . $data; + $success = 1; + } otherwise { - my $e = shift; ++ my $e = shift; + my $err = "Invalid XML: $e"; + $r->log->error($err); + $r->content_type('text/plain; encoding=utf8'); + $r->print("\n$err\n\n$data"); + }; + + $r->print($data) if ($success); + } + + + sub load_context { + my $r = shift; + my $cgi = CGI->new; - my $ctx = $web_config->{ctx}; ++ my $ctx = {}; # new context for each page load ++ $ctx->{$_} = $web_config->{base_ctx}->{$_} for keys %{$web_config->{base_ctx}}; + $ctx->{hostname} = $r->hostname; + $ctx->{base_url} = $cgi->url(-base => 1); + $ctx->{skin} = $cgi->cookie(OILS_HTTP_COOKIE_SKIN) || 'default'; + $ctx->{theme} = $cgi->cookie(OILS_HTTP_COOKIE_THEME) || 'default'; + $ctx->{locale} = + $cgi->cookie(OILS_HTTP_COOKIE_LOCALE) || + parse_accept_lang($r->headers_in->get('Accept-Language')) || 'en-US'; + $r->log->debug('skin = ' . $ctx->{skin} . ' : theme = ' . + $ctx->{theme} . ' : locale = ' . $ctx->{locale}); ++ ++ my $mprefix = $ctx->{media_prefix}; ++ if($mprefix and $mprefix !~ /^http/ and $mprefix !~ /^\//) { ++ # if a hostname is provided /w no protocol, match the protocol to the current page ++ $ctx->{media_prefix} = ($cgi->https) ? "https://$mprefix" : "http://$mprefix"; ++ } ++ ++ + return $ctx; + } + + # turn Accept-Language into sometihng EG can understand + sub parse_accept_lang { + my $al = shift; + return undef unless $al; + my ($locale) = split(/,/, $al); + ($locale) = split(/;/, $locale); + return undef unless $locale; + $locale =~ s/-(.*)/eval '-'.uc("$1")/e; + return $locale; + } + + # Given a URI, finds the configured template and any extra page + # arguments (trailing path info). Any extra data is returned + # as page arguments, in the form of an array, one item per + # /-separated URI component + sub find_template { + my $r = shift; + my $base = shift; + my $ctx = shift; + my $skin = $ctx->{skin}; + my $path = $r->uri; + $path =~ s/$base//og; + my @parts = split('/', $path); + my $template = ''; + my $page_args = []; + my $as_xml = $ctx->{force_valid_xml}; + my $handler = $web_config->{handlers}; + + while(@parts) { + my $part = shift @parts; + next unless $part; + my $t = $handler->{$part}; + if(ref($t) eq 'PathConfig') { + $template = $t->{template}; + $as_xml = ($t->{as_xml} and $t->{as_xml} =~ /true/io) || $as_xml; + $page_args = [@parts]; + last; + } else { + $handler = $t; + } + } + + unless($template) { # no template configured + + # see if we can magically find the template based on the path and default extension + my $ext = $ctx->{default_template_extension}; + + my @parts = split('/', $path); + my $localpath = $path; + my @args; + while(@parts) { + last unless $localpath; + for my $tpath (@{$ctx->{template_paths}}) { + my $fpath = "$tpath/$skin/$localpath.$ext"; + $r->log->debug("looking at possible template $fpath"); + if(-r $fpath) { + $template = "$localpath.$ext"; + last; + } + } + last if $template; + push(@args, pop @parts); + $localpath = '/'.join('/', @parts); + } + + $page_args = [@args]; + + # no template configured or found + unless($template) { + $r->log->warn("No template configured for path $path"); + return (); + } + } + + $r->log->debug("template = $template : page args = @$page_args"); + return ($template, $page_args, $as_xml); + } + + # if the web configuration file has never been loaded or has + # changed since the last load, reload it + sub check_web_config { + my $r = shift; + my $epoch = stat($web_config_file)->mtime; + unless($web_config_edit_time and $web_config_edit_time == $epoch) { + $r->log->debug("Reloading web config after edit...") if $r; + $web_config_edit_time = $epoch; + $web_config = parse_config($web_config_file); + } + } + + sub parse_config { + my $cfg_file = shift; + my $data = XML::Simple->new->XMLin($cfg_file); + my $ctx = {}; + my $handlers = {}; + + $ctx->{media_prefix} = (ref $data->{media_prefix}) ? '' : $data->{media_prefix}; + $ctx->{base_path} = (ref $data->{base_path}) ? '' : $data->{base_path}; + $ctx->{template_paths} = []; - $ctx->{force_valid_xml} = ($data->{force_valid_xml} =~ /true/io) ? 1 : 0; ++ $ctx->{force_valid_xml} = ( ($data->{force_valid_xml}||'') =~ /true/io) ? 1 : 0; ++ $ctx->{debug_template} = ( ($data->{debug_template}||'') =~ /true/io) ? 1 : 0; + $ctx->{default_template_extension} = $data->{default_template_extension} || 'tt2'; + $ctx->{web_dir} = $data->{web_dir}; + + my $tpaths = $data->{template_paths}->{path}; + $tpaths = [$tpaths] unless ref $tpaths; + push(@{$ctx->{template_paths}}, $_) for @$tpaths; + + for my $handler (@{$data->{handlers}->{handler}}) { + my @parts = split('/', $handler->{path}); + my $h = $handlers; + my $pcount = scalar(@parts); + for(my $i = 0; $i < $pcount; $i++) { + my $p = $parts[$i]; + unless(defined $h->{$p}) { + if($i == $pcount - 1) { + $h->{$p} = PathConfig->new(%$handler); + last; + } else { + $h->{$p} = {}; + } + } + $h = $h->{$p}; + } + } + - return {ctx => $ctx, handlers => $handlers}; ++ return {base_ctx => $ctx, handlers => $handlers}; + } + + package PathConfig; + sub new { + my($class, %args) = @_; + return bless(\%args, $class); + } + + + 1;