From d0fb7125328b055ab18ba31c78c3a8cf38c7d9d2 Mon Sep 17 00:00:00 2001 From: Mike Rylander Date: Fri, 6 Sep 2019 14:49:52 -0400 Subject: [PATCH] LP#1838995: Hold subscription buckets This feature allows staff to add multiple users to a named hold subscription bucket and place title-level holds for a record for that entire set of users. Users can be added to such a hold subscription bucket from either the patron search result interface, via the Add to Bucket dropdown, or through a dedicated Hold Subscription interface available from the Circulation menu. Adding new patrons to a subscription bucket will require staff have the PLACE_HOLD permission. Holds can be placed for the users in a subscription bucket either directly from the normal staff-place hold interface in the embedded OPAC, or by supplying the record ID within the subscription bucket interface. In the latter case, the list of users for which a hold was attempted but failed to be placed can be downloaded by staff in order to address any placement issues. Placing a subscription bucket hold will requires staff have the MANAGE_BATCH_HOLDS permission, which is new with this development. In the event of a mistaken subscription hold, staff with the MANAGE_BATCH_HOLDS permission will have the ability to cancel all unfulfilled holds created as part of a subscription hold event. A link to the title's hold interface is available from the list of subscription hold events in the dedicated subscription hold interface. Signed-off-by: Mike Rylander --- Open-ILS/examples/fm_IDL.xml | 41 + .../lib/OpenILS/Application/Actor/Container.pm | 32 +- .../perlmods/lib/OpenILS/Application/Circ/Holds.pm | 244 ++++++ .../src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm | 3 + .../lib/OpenILS/WWW/EGCatLoader/Account.pm | 134 +++- .../perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm | 35 + .../src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql | 125 +++ Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 | 5 + .../templates/opac/myopac/ebook_holds_ready.tt2 | 5 + .../src/templates/opac/myopac/hold_history.tt2 | 5 + .../templates/opac/myopac/hold_subscriptions.tt2 | 76 ++ Open-ILS/src/templates/opac/myopac/holds.tt2 | 5 + Open-ILS/src/templates/opac/parts/place_hold.tt2 | 26 +- .../staff/cat/bucket/batch_hold/index.tt2 | 82 ++ .../staff/cat/bucket/batch_hold/t_bucket_info.tt2 | 21 + .../staff/cat/bucket/batch_hold/t_event.tt2 | 42 + .../staff/cat/bucket/batch_hold/t_event_create.tt2 | 30 + .../staff/cat/bucket/batch_hold/t_grid_menu.tt2 | 20 + .../staff/cat/bucket/batch_hold/t_list.tt2 | 22 + .../staff/cat/bucket/batch_hold/t_pending.tt2 | 58 ++ .../staff/cat/bucket/batch_hold/t_view.tt2 | 29 + .../staff/cat/bucket/share/t_bucket_create.tt2 | 9 +- .../staff/cat/bucket/share/t_bucket_edit.tt2 | 7 +- Open-ILS/src/templates/staff/circ/patron/index.tt2 | 6 + .../staff/circ/patron/t_hold_subscriptions.tt2 | 21 + .../staff/circ/patron/t_search_results.tt2 | 4 + Open-ILS/src/templates/staff/navbar.tt2 | 6 + Open-ILS/web/js/ui/default/opac/staff.js | 132 ++- .../ui/default/staff/cat/bucket/batch_hold/app.js | 889 +++++++++++++++++++++ .../web/js/ui/default/staff/circ/patron/app.js | 83 ++ .../js/ui/default/staff/services/user-bucket.js | 14 + 31 files changed, 2133 insertions(+), 78 deletions(-) create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql create mode 100644 Open-ILS/src/templates/opac/myopac/hold_subscriptions.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/index.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_bucket_info.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event_create.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_grid_menu.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_list.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_pending.tt2 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_view.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_hold_subscriptions.tt2 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/bucket/batch_hold/app.js diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 75b52a7597..6c26555d64 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -7277,6 +7277,47 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm index abeeea2e9b..5e644d4be7 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm @@ -142,7 +142,10 @@ sub _bucket_flesh { unless($bkt->owner eq $e->requestor->id) { my $owner = $e->retrieve_actor_user($bkt->owner) or return $e->die_event; - return $e->event unless $e->allowed('VIEW_CONTAINER', $owner->home_ou); + return $e->event unless ( + $e->allowed('VIEW_CONTAINER', $owner->home_ou) or + $e->allowed('VIEW_CONTAINER', $bkt->owning_lib) + ); } } @@ -335,6 +338,7 @@ __PACKAGE__->register_method( {desc => 'Authentication token', type => 'string'}, {desc => 'Container class. Can be "copy", "callnumber", "biblio", or "user"', type => 'string'}, {desc => 'Item or items. Can either be a single container item object, or an array of them', type => 'object'}, + {desc => 'Duplicate check. Avoid adding an item that is already in a container', type => 'bool'}, ], return => { desc => 'The ID of the newly created item(s). In batch context, an array of IDs is returned' @@ -344,7 +348,7 @@ __PACKAGE__->register_method( sub item_create { - my( $self, $client, $authtoken, $class, $item ) = @_; + my( $self, $client, $authtoken, $class, $item, $dupe_check ) = @_; my $e = new_editor(xact=>1, authtoken=>$authtoken); return $e->die_event unless $e->checkauth; @@ -369,21 +373,45 @@ sub item_create { my $stat; if( $class eq 'copy' ) { + next if ( + $dupe_check && + $e->search_container_copy_bucket_item( + {bucket => $one_item->bucket, target_copy => $one_item->target_copy} + )->[0] + ); return $e->die_event unless $stat = $e->create_container_copy_bucket_item($one_item); } if( $class eq 'callnumber' ) { + next if ( + $dupe_check && + $e->search_container_call_number_bucket_item( + {bucket => $one_item->bucket, target_call_number => $one_item->target_call_number} + )->[0] + ); return $e->die_event unless $stat = $e->create_container_call_number_bucket_item($one_item); } if( $class eq 'biblio' ) { + next if ( + $dupe_check && + $e->search_container_biblio_record_entry_bucket_item( + {bucket => $one_item->bucket, target_biblio_record_entry => $one_item->target_biblio_record_entry} + )->[0] + ); return $e->die_event unless $stat = $e->create_container_biblio_record_entry_bucket_item($one_item); } if( $class eq 'user') { + next if ( + $dupe_check && + $e->search_container_user_bucket_item( + {bucket => $one_item->bucket, target_user => $one_item->target_user} + )->[0] + ); return $e->die_event unless $stat = $e->create_container_user_bucket_item($one_item); } diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm index e19915e75c..9e6743a841 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm @@ -17,6 +17,7 @@ package OpenILS::Application::Circ::Holds; use base qw/OpenILS::Application/; use strict; use warnings; +use List::Util qw(shuffle); use OpenILS::Application::AppUtils; use DateTime; use Data::Dumper; @@ -165,6 +166,249 @@ sub test_and_create_hold_batch { return undef; } +__PACKAGE__->register_method( + method => "test_and_create_batch_hold_event", + api_name => "open-ils.circ.holds.test_and_create.subscription_batch", + stream => 1, + signature => { + desc => q/This is for batch creating a set of holds where every field is identical except for the target users./, + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Hash of named parameters. Same as for open-ils.circ.title_hold.is_possible, though the pertinent target field is automatically populated based on the hold_type and the specified list of target users.', type => 'object'}, + { desc => 'Container ID of the user bucket holding target users', type => 'number' }, + { desc => 'target object ID (clarified by hold_type in param hash)', type => 'number' }, + { desc => 'Randomize flag, set to 0 for "not randomized"', type => 'bool' } + ], + return => { + desc => 'Stream of objects structured as {total=>X, count=>Y, target=>Z, patronid=>A, result=>$hold_id} on success, -1 on missing arg, event (or stream of events on "result" key of object) on error(s)', + }, + } +); + +__PACKAGE__->register_method( + method => "test_and_create_batch_hold_event", + api_name => "open-ils.circ.holds.test_and_create.subscription_batch.override", + stream => 1, + signature => { + desc => '@see open-ils.circ.holds.test_and_create.subscription_batch', + } +); + + +sub test_and_create_batch_hold_event { + my( $self, $conn, $auth, $params, $target_bucket, $target_id, $randomize, $oargs ) = @_; + + + $randomize //= 1; # default to random hold creation order + $$params{hold_type} //= 'T'; # default to title holds + + my $override = 0; + if ($self->api_name =~ /override/) { + $override = 1; + $oargs = { all => 1 } unless defined $oargs; + $$params{oargs} = $oargs; # for is_possible checking. + } + + my $e = new_editor(authtoken=>$auth); + return $e->die_event unless $e->checkauth; + $$params{'requestor'} = $e->requestor->id; + + + my $org = $e->requestor->ws_ou || $e->requestor->home_ou; + # the perm locaiton shouldn't really matter here since holds + # will exist all over and MANAGE_BATCH_HOLDS should be universal + my $evt = $U->check_perms($e->requestor->id, $org, 'MANAGE_BATCH_HOLDS'); + return $evt if $evt; + + my $rand_setting = $U->ou_ancestor_setting_value($org, 'holds.subscription.randomize'); + $randomize = $rand_setting if (defined $rand_setting); + + my $target_field; + if ($$params{'hold_type'} eq 'T') { $target_field = 'titleid'; } + elsif ($$params{'hold_type'} eq 'C') { $target_field = 'copy_id'; } + elsif ($$params{'hold_type'} eq 'R') { $target_field = 'copy_id'; } + elsif ($$params{'hold_type'} eq 'F') { $target_field = 'copy_id'; } + elsif ($$params{'hold_type'} eq 'I') { $target_field = 'issuanceid'; } + elsif ($$params{'hold_type'} eq 'V') { $target_field = 'volume_id'; } + elsif ($$params{'hold_type'} eq 'M') { $target_field = 'mrid'; } + elsif ($$params{'hold_type'} eq 'P') { $target_field = 'partid'; } + else { return undef; } + + # Check for a valid record. + # XXX For now, because only title holds are allowed, we'll add only that check. + my $target_check = $e->json_query({ + select => {bre => ['id']}, + from => 'bre', + where => {deleted => 'f', id => $target_id} + }); + return {error=>'invalid_target'} if (!@$target_check); + + my $formats_map = delete($$params{holdable_formats_map}) || {}; + + my $target_list = $e->search_container_user_bucket_item({bucket => $target_bucket}); + @$target_list = shuffle(@$target_list) if $randomize; + + # Record the request... + $e->xact_begin; + my $bhe = Fieldmapper::action::batch_hold_event->new; + $bhe->isnew(1); + $bhe->staff($e->requestor->id); + $bhe->bucket($target_bucket); + $bhe->target($target_id); + $bhe->hold_type($$params{hold_type}); + $bhe = $e->create_action_batch_hold_event($bhe) or return $e->die_event; + $e->xact_commit; + + my $total = scalar(@$target_list); + my $count = 0; + $conn->respond({total => $total, count => $count}); + + my $triggers = OpenSRF::AppSession->connect('open-ils.trigger'); + foreach (@$target_list) { + $count++; + $$params{$target_field} = $target_id; + $$params{patronid} = $_->target_user; + + my $usr = $e->retrieve_actor_user([ + $$params{patronid}, + { + flesh => 1, + flesh_fields => { + au => ['settings'] + } + } + ]); + my $user_setting_map = { + map { $_->name => OpenSRF::Utils::JSON->JSON2perl($_->value) } + @{ $usr->settings } + }; + + $$params{pickup_lib} = $$user_setting_map{'opac.default_pickup_location'} || $usr->home_ou; + + if ($user_setting_map->{'opac.hold_notify'} =~ /email/) { + $$params{email_notify} = 1; + } else { + delete $$params{email_notify}; + } + + if ($user_setting_map->{'opac.default_phone'} && $user_setting_map->{'opac.hold_notify'} =~ /phone/) { + $$params{phone_notify} = $user_setting_map->{'opac.default_phone'}; + } else { + delete $$params{phone_notify}; + } + + if ($user_setting_map->{'opac.default_sms_carrier'} + && $user_setting_map->{'opac.default_sms_notify'} + && $user_setting_map->{'opac.hold_notify'} =~ /sms/) { + $$params{sms_carrier} = $user_setting_map->{'opac.default_sms_carrier'}; + $$params{sms_notify} = $user_setting_map->{'opac.default_sms_notify'}; + } else { + delete $$params{sms_carrier}; + delete $$params{sms_notify}; + } + + # copy the requested formats from the target->formats map + # into the top-level formats attr for each hold ... empty for now, T holds only + $$params{holdable_formats} = $formats_map->{$_}; + + my $res; + ($res) = $self->method_lookup( + 'open-ils.circ.title_hold.is_possible')->run($auth, $params, $override ? $oargs : {}); + if ($res->{'success'} == 1) { + + $params->{'depth'} = $res->{'depth'} if $res->{'depth'}; + + # Remove oargs from params so holds can be created. + if ($$params{oargs}) { + delete $$params{oargs}; + } + + my $ahr = construct_hold_request_object($params); + my ($res2) = $self->method_lookup( + $override + ? 'open-ils.circ.holds.create.override' + : 'open-ils.circ.holds.create' + )->run($auth, $ahr, $oargs); + $res2 = { + total => $total, count => $count, + 'patronid' => $$params{patronid}, + 'target' => $$params{$target_field}, + 'result' => $res2 + }; + $conn->respond($res2); + + unless (ref($res2->{result})) { # success returns a hold id only + $e->xact_begin; + my $bhem = Fieldmapper::action::batch_hold_event_map->new; + $bhem->isnew(1); + $bhem->batch_hold_event($bhe->id); + $bhem->hold($res2->{result}); + $e->create_action_batch_hold_event_map($bhem) or return $e->die_event; + $e->xact_commit; + + my $hold = $e->retrieve_action_hold_request($bhem->hold); + $triggers->request('open-ils.trigger.event.autocreate', 'hold_request.success', $hold, $hold->pickup_lib); + } + + } else { + $res = { + total => $total, count => $count, + 'target' => $$params{$target_field}, + 'failedpatronid' => $$params{patronid}, + 'result' => $res + }; + $conn->respond($res); + } + } + $triggers->kill_me; + return undef; +} + +__PACKAGE__->register_method( + method => "rollback_batch_hold_event", + api_name => "open-ils.circ.holds.rollback.subscription_batch", + stream => 1, + signature => { + desc => q/This is for batch creating a set of holds where every field is identical except for the target users./, + params => [ + { desc => 'Authentication token', type => 'string' }, + { desc => 'Batch Hold Event ID to roll back', type => 'number' }, + ], + return => { + desc => 'Stream of objects structured as {total=>X, count=>Y} on success, event on error', + }, + } +); + +sub rollback_batch_hold_event { + my( $self, $conn, $auth, $event_id ) = @_; + + my $e = new_editor(authtoken=>$auth,xact=>1); + return $e->die_event unless $e->checkauth; + + my $org = $e->requestor->ws_ou || $e->requestor->home_ou; + my $evt = $U->check_perms($e->requestor->id, $org, 'MANAGE_BATCH_HOLDS'); + return $evt if $evt; + + my $batch_event = $e->retrieve_action_batch_hold_event($event_id); + my $target_list = $e->search_action_batch_hold_event_map({batch_hold_event => $event_id}); + + my $total = scalar(@$target_list); + my $count = 0; + $conn->respond({total => $total, count => $count}); + + for my $target (@$target_list) { + $count++; + $self->method_lookup('open-ils.circ.hold.cancel')->run($auth, $target->hold, 8); + $conn->respond({ total => $total, count => $count }); + } + + $batch_event->cancelled('now'); + $e->update_action_batch_hold_event($batch_event); + $e->commit; + return undef; +} + sub construct_hold_request_object { my ($params) = @_; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm index 105c3bd1af..fe5dc2e33c 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm @@ -207,6 +207,7 @@ sub load { return $self->load_place_hold if $path =~ m|opac/place_hold|; return $self->load_myopac_holds if $path =~ m|opac/myopac/holds|; + return $self->load_myopac_hold_subscriptions if $path =~ m|opac/myopac/hold_subscriptions|; return $self->load_myopac_circs if $path =~ m|opac/myopac/circs|; return $self->load_myopac_messages if $path =~ m|opac/myopac/messages|; return $self->load_myopac_payment_form if $path =~ m|opac/myopac/main_payment_form|; @@ -353,6 +354,8 @@ sub load_common { $self->staff_saved_searches_set_expansion_state if $ctx->{is_staff}; $self->load_eg_cache_hash; $self->load_copy_location_groups; + $self->load_my_hold_subscriptions; + $self->load_hold_subscriptions if $ctx->{is_staff}; $self->staff_saved_searches_set_expansion_state if $ctx->{is_staff}; $self->load_search_filter_groups($ctx->{search_ou}); $self->load_org_util_funcs; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm index 5bc2f8b98c..727af060cb 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm @@ -1023,6 +1023,30 @@ sub load_myopac_holds { return defined($hold_handle_result) ? $hold_handle_result : Apache2::Const::OK; } +sub load_myopac_hold_subscriptions { + my $self = shift; + my $e = $self->editor; + my $ctx = $self->ctx; + + my $sub_remove = $self->cgi->param('remove'); + + if ($sub_remove and $ctx->{user}->id) { + my $sub_entries = $self->editor->search_container_user_bucket_item( + { bucket => $sub_remove, target_user => $ctx->{user}->id } + ); + + $self->editor->xact_begin; + $self->editor->delete_container_user_bucket_item($_) for @$sub_entries; + $self->editor->xact_commit; + + return $self->generic_redirect( + $self->ctx->{proto} . '://' . $self->ctx->{hostname} . $self->ctx->{opac_root} . '/myopac/hold_subscriptions' + ); + } + + return Apache2::Const::OK; +} + my $data_filler; sub load_place_hold { @@ -1036,6 +1060,7 @@ sub load_place_hold { my @targets = uniq $cgi->param('hold_target'); my @parts = $cgi->param('part'); + $ctx->{hold_subscription} = $cgi->param('hold_subscription'); $ctx->{hold_type} = $cgi->param('hold_type'); $ctx->{default_pickup_lib} = $e->requestor->home_ou; # unless changed below $ctx->{email_notify} = $cgi->param('email_notify'); @@ -1264,8 +1289,8 @@ sub load_place_hold { $_->{marc_xml} = XML::LibXML->new->parse_string($_->{record}->marc) for @hold_data; my $pickup_lib = $cgi->param('pickup_lib'); - # no pickup lib means no holds placement - return Apache2::Const::OK unless $pickup_lib; + # no pickup lib means no holds placement, except for subscriptions + return Apache2::Const::OK unless $pickup_lib || $ctx->{hold_subscription}; $ctx->{hold_attempt_made} = 1; @@ -1278,17 +1303,26 @@ sub load_place_hold { my $usr = $e->requestor->id; - if ($ctx->{is_staff} and !$cgi->param("hold_usr_is_requestor")) { - # find the real hold target + if ($ctx->{is_staff}) { + $logger->info("Staff initiated hold"); + if (!$cgi->param("hold_usr_is_requestor")) { + # find the real hold target - $usr = $U->simplereq( - 'open-ils.actor', - "open-ils.actor.user.retrieve_id_by_barcode_or_username", - $e->authtoken, $cgi->param("hold_usr")); + $usr = $U->simplereq( + 'open-ils.actor', + "open-ils.actor.user.retrieve_id_by_barcode_or_username", + $e->authtoken, $cgi->param("hold_usr")); + + if (defined $U->event_code($usr)) { + $ctx->{hold_failed} = 1; + $ctx->{hold_failed_event} = $usr; + } + } - if (defined $U->event_code($usr)) { - $ctx->{hold_failed} = 1; - $ctx->{hold_failed_event} = $usr; + if ($ctx->{hold_subscription}) { + # this is a batch event, hold "user" is a bucket id + $logger->info("Hold Subscription Event requested for user bucket: " . $ctx->{hold_subscription}); + $usr = $e->retrieve_container_user_bucket($ctx->{hold_subscription}); } } @@ -1354,22 +1388,32 @@ sub attempt_hold_placement { my $ctx = $self->ctx; my $e = $self->editor; + my $user_container = undef; + if (ref($usr)) { # $usr is actually a container for a subscription... + $user_container = $usr->id; + return unless ($hold_type eq 'T'); # Only T-hold subscriptions for now. + } + # First see if we should warn/block for any holds that - # might have locally available items. - for my $hdata (@hold_data) { - my ($local_block, $local_alert) = $self->local_avail_concern( - $hdata->{target_id}, $hold_type, $pickup_lib); - - if ($local_block) { - $hdata->{hold_failed} = 1; - $hdata->{hold_local_block} = 1; - } elsif ($local_alert) { - $hdata->{hold_failed} = 1; - $hdata->{hold_local_alert} = 1; + # might have locally available items for non-subscriptions. + if (!$user_container) { + for my $hdata (@hold_data) { + my ($local_block, $local_alert) = $self->local_avail_concern( + $hdata->{target_id}, $hold_type, $pickup_lib); + + if ($local_block) { + $hdata->{hold_failed} = 1; + $hdata->{hold_local_block} = 1; + } elsif ($local_alert) { + $hdata->{hold_failed} = 1; + $hdata->{hold_local_alert} = 1; + } } } - my $method = 'open-ils.circ.holds.test_and_create.batch'; + my $method = $user_container + ? 'open-ils.circ.holds.test_and_create.subscription_batch' + : 'open-ils.circ.holds.test_and_create.batch'; if ($cgi->param('override')) { $method .= '.override'; @@ -1394,17 +1438,31 @@ sub attempt_hold_placement { } my $bses = OpenSRF::AppSession->create('open-ils.circ'); - my $breq = $bses->request( - $method, - $e->authtoken, - $data_filler->({ - patronid => $usr, - pickup_lib => $pickup_lib, - hold_type => $hold_type, - holdable_formats_map => $holdable_formats, - }), - \@create_targets - ); + + my @create_params = (); + + if ($user_container) { + @create_params = ( + $data_filler->({ + hold_type => $hold_type, + holdable_formats_map => $holdable_formats, # currently always unset, only T holds + }), + $user_container, + $create_targets[0], + ); + } else { + @create_params = ( + $data_filler->({ + patronid => $usr, + pickup_lib => $pickup_lib, + hold_type => $hold_type, + holdable_formats_map => $holdable_formats, + }), + \@create_targets + ); + } + + my $breq = $bses->request($method, $e->authtoken, @create_params); while (my $resp = $breq->recv) { @@ -1416,6 +1474,13 @@ sub attempt_hold_placement { last; } + # subscription batch create sends an initial response to assist with client-side counting + next if ( + $user_container and + defined($$resp{count}) and $$resp{count} == 0 + and defined($$resp{total}) and $$resp{total} > 0 + ); + # Skip those that had the hold_success or hold_failed fields set for duplicate holds placement. my ($hdata) = grep {$_->{target_id} eq $resp->{target} && !($_->{hold_failed} || $_->{hold_success})} @hold_data; my $result = $resp->{result}; @@ -1429,7 +1494,6 @@ sub attempt_hold_placement { if(not ref $result and $result > 0) { # successul hold returns the hold ID - $hdata->{hold_success} = $result; } else { diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm index 730586a128..f741bcaa01 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm @@ -3,6 +3,7 @@ use strict; use warnings; use Apache2::Const -compile => qw(OK DECLINED FORBIDDEN HTTP_INTERNAL_SERVER_ERROR REDIRECT HTTP_BAD_REQUEST); use File::Spec; use Time::HiRes qw/time sleep/; +use List::MoreUtils qw(uniq); use OpenSRF::Utils::Cache; use OpenSRF::Utils::Logger qw/$logger/; use OpenILS::Utils::CStoreEditor qw/:funcs/; @@ -701,6 +702,40 @@ sub load_copy_location_groups { $ctx->{copy_location_groups} = \%buckets; } +sub load_hold_subscriptions { + my $self = shift; + my $ctx = $self->ctx; + + return unless $ctx->{authtoken}; + + my $e = new_editor(authtoken => $ctx->{authtoken}); + $e->personality('open-ils.pcrud'); # use pcrud mode to filter appropriately + + $ctx->{hold_subscriptions} = + $e->search_container_user_bucket([ + { btype => 'hold_subscription' }, + { order_by => {cub => 'name'} } + ]); + +} + +sub load_my_hold_subscriptions { + my $self = shift; + my $ctx = $self->ctx; + + return unless $ctx->{authtoken}; + + my $sub_entries = $self->editor->search_container_user_bucket_item( + { target_user => $ctx->{user}->id } + ); + + my $sub_ids = [ uniq map { $_->bucket } @$sub_entries ]; + $ctx->{my_hold_subscriptions} = scalar(@$sub_ids) ? + $self->editor->search_container_user_bucket( + {btype => 'hold_subscription', id => $sub_ids, pub => 't'} + ) : []; +} + sub set_file_download_headers { my $self = shift; my $filename = shift; diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql new file mode 100644 index 0000000000..ac534fd965 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql @@ -0,0 +1,125 @@ + +BEGIN; + +CREATE TABLE action.batch_hold_event ( + id SERIAL PRIMARY KEY, + staff INT NOT NULL REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE, + bucket INT NOT NULL REFERENCES container.user_bucket (id) ON UPDATE CASCADE ON DELETE CASCADE, + target INT NOT NULL, + hold_type TEXT NOT NULL DEFAULT 'T', -- maybe different hold types in the future... + run_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + cancelled TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE action.batch_hold_event_map ( + id SERIAL PRIMARY KEY, + batch_hold_event INT NOT NULL REFERENCES action.batch_hold_event (id) ON UPDATE CASCADE ON DELETE CASCADE, + hold INT NOT NULL REFERENCES action.hold_request (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +INSERT INTO container.user_bucket_type (code,label) VALUES ('hold_subscription','Hold Subscription Container'); + +INSERT INTO config.org_unit_setting_type + (name, label, description, grp, datatype) +VALUES ( + 'holds.subscription.randomize', + oils_i18n_gettext( + 'holds.subscription.randomize', + 'Randomize subscription hold order', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'holds.subscription.randomize', + 'When placing a batch subscription hold, randomize the order of the patrons receiving the holds so they are not always in the same order.', + 'coust', + 'description' + ), + 'holds', + 'bool' +); + +-- Committer! Verify permission id before pushing! +INSERT INTO permission.perm_list (id,code,description) + VALUES ( 620, 'MANAGE_BATCH_HOLDS', oils_i18n_gettext(620, 'Manage batch (subscription) hold events', 'ppl', 'description')); + +INSERT INTO action.hold_request_cancel_cause (id,label) + VALUES ( 8, oils_i18n_gettext(8, 'Subscription Event rollback', 'ahrcc', 'label')); + +INSERT INTO action_trigger.event_definition (active, owner, name, hook, validator, reactor, delay, delay_field, group_field, cleanup_success, template) + VALUES ('f', 1, 'Subscription Hold Placed for Patron Email Notification', 'hold_request.success', 'NOOP_True', 'SendEmail', '30 minutes', 'request_time', 'usr', 'CreateHoldNotification', +$$ +[%- USE date -%] +[%- user = target.0.usr -%] +To: [%- params.recipient_email || user.email %] +From: [%- params.sender_email || default_sender %] +Date: [%- date.format(date.now, '%a, %d %b %Y %T -0000', gmt => 1) %] +Subject: Subcription Hold placed for you +Auto-Submitted: auto-generated + +Dear [% user.family_name %], [% user.first_given_name %] +The following items have been placed on hold for you: + +[% FOR hold IN target %] + [%- copy_details = helpers.get_copy_bib_basics(hold.current_copy.id) -%] + Title: [% copy_details.title %] + Author: [% copy_details.author %] + Call Number: [% hold.current_copy.call_number.label %] + Barcode: [% hold.current_copy.barcode %] + Library: [% hold.pickup_lib.name %] +[% END %] + +$$); + +INSERT INTO action_trigger.environment (event_def, path ) VALUES +( currval('action_trigger.event_definition_id_seq'), 'usr' ), +( currval('action_trigger.event_definition_id_seq'), 'pickup_lib' ), +( currval('action_trigger.event_definition_id_seq'), 'current_copy.call_number' ); + + +INSERT INTO action_trigger.event_definition ( + active, owner, name, hook, validator, reactor, cleanup_success, + delay, delay_field, group_field, template +) VALUES ( + false, 1, 'Subscription Hold Placed for Patron SMS Notification', 'hold_request.success', 'NOOP_True', + 'SendSMS', 'CreateHoldNotification', '00:30:00', 'shelf_time', 'sms_notify', + '[%- USE date -%] +[%- user = target.0.usr -%] +From: [%- params.sender_email || default_sender %] +Date: [%- date.format(date.now, ''%a, %d %b %Y %T -0000'', gmt => 1) %] +To: [%- params.recipient_email || helpers.get_sms_gateway_email(target.0.sms_carrier,target.0.sms_notify) %] +Subject: [% target.size %] subscription hold(s) placed for you +Auto-Submitted: auto-generated + +[% FOR hold IN target %][%- + bibxml = helpers.xml_doc( hold.current_copy.call_number.record.marc ); + title = ""; + FOR part IN bibxml.findnodes(''//*[@tag="245"]/*[@code="a"]''); + title = title _ part.textContent; + END; + author = bibxml.findnodes(''//*[@tag="100"]/*[@code="a"]'').textContent; +%][% hold.usr.first_given_name %]:[% title %] @ [% hold.pickup_lib.name %] +[% END %] +' +); + +INSERT INTO action_trigger.environment ( + event_def, + path +) VALUES ( + currval('action_trigger.event_definition_id_seq'), + 'current_copy.call_number.record.simple_record' +), ( + currval('action_trigger.event_definition_id_seq'), + 'usr' +), ( + currval('action_trigger.event_definition_id_seq'), + 'pickup_lib.billing_address' +); + +INSERT INTO action_trigger.event_params (event_def, param, value) + VALUES (currval('action_trigger.event_definition_id_seq'), 'check_sms_notify', 1); + + +COMMIT; + diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 index f862ed6c6a..3cc6a83c67 100644 --- a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 +++ b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 @@ -21,6 +21,11 @@ + [% IF ctx.my_hold_subscriptions.size > 0 %] + + [% END %] diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 index 55806d5f6e..c5b1f11bd8 100644 --- a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 +++ b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 @@ -21,6 +21,11 @@ + [% IF ctx.my_hold_subscriptions.size > 0 %] + + [% END %] diff --git a/Open-ILS/src/templates/opac/myopac/hold_history.tt2 b/Open-ILS/src/templates/opac/myopac/hold_history.tt2 index 068557fd47..bbd0b20f27 100644 --- a/Open-ILS/src/templates/opac/myopac/hold_history.tt2 +++ b/Open-ILS/src/templates/opac/myopac/hold_history.tt2 @@ -23,6 +23,11 @@ [% l("E-Items Ready for Checkout") %] [% END %] + [% IF ctx.my_hold_subscriptions.size > 0 %] + + [% END %] diff --git a/Open-ILS/src/templates/opac/myopac/hold_subscriptions.tt2 b/Open-ILS/src/templates/opac/myopac/hold_subscriptions.tt2 new file mode 100644 index 0000000000..79fa632d0c --- /dev/null +++ b/Open-ILS/src/templates/opac/myopac/hold_subscriptions.tt2 @@ -0,0 +1,76 @@ +[% PROCESS "opac/parts/header.tt2"; + PROCESS "opac/parts/misc_util.tt2"; + PROCESS "opac/parts/hold_status.tt2"; + WRAPPER "opac/parts/myopac/base.tt2"; + myopac_page = "holds"; + limit = ctx.hold_history_limit; + offset = ctx.hold_history_offset; + count = ctx.hold_history_ids.size; +%] + +

[% l('Holds History') %]

+
+ +
+ + [% IF ebook_api.enabled == 'true' %] + + + [% END %] + [% IF ctx.my_hold_subscriptions.size > 0 %] + + [% END %] + +
+ +
+ [% l("Current Hold Subscriptions") %] + + [% l('Export List') %] + +
+
+ +
+ [% IF ctx.my_hold_subscriptions.size AND ctx.my_hold_subscriptions.size < 1 %] +
+ [% l('No subscriptions found.') %] +
+ [% ELSE %] + + + + + + + + + + [% FOR sub IN ctx.my_hold_subscriptions %] + + + + + + + [% END %] + +
[% l('Name') %][% l('Description') %][% l('Actions') %]
[% sub.name | html %] [% sub.description | html %] + sub.id}) %]'> + [% l('Remove me') %] + +
+ [% END %] +
+
+[% END %] diff --git a/Open-ILS/src/templates/opac/myopac/holds.tt2 b/Open-ILS/src/templates/opac/myopac/holds.tt2 index d881d9f3a3..ae2b0f07fa 100644 --- a/Open-ILS/src/templates/opac/myopac/holds.tt2 +++ b/Open-ILS/src/templates/opac/myopac/holds.tt2 @@ -23,6 +23,11 @@ [% l("E-Items Ready for Checkout") %] [% END %] + [% IF ctx.my_hold_subscriptions.size > 0 %] + + [% END %] diff --git a/Open-ILS/src/templates/opac/parts/place_hold.tt2 b/Open-ILS/src/templates/opac/parts/place_hold.tt2 index 1f0989d81f..5d811ed5cb 100644 --- a/Open-ILS/src/templates/opac/parts/place_hold.tt2 +++ b/Open-ILS/src/templates/opac/parts/place_hold.tt2 @@ -97,6 +97,7 @@ function maybeToggleNumCopies(obj) { %] [% IF ctx.is_staff %] +

+ [% IF CGI.param('hold_type') == 'T' AND ctx.hold_subscriptions.size > 0 AND NOT CGI.param('from_basket') %] +
+ + + + + [% END %] +
+

[% END %] + [% END %] @@ -244,7 +268,7 @@ function maybeToggleNumCopies(obj) { [% IF ctx.default_phone_notify %]checked="checked"[% END %]/>
-
diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/index.tt2 new file mode 100644 index 0000000000..841f59c406 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/index.tt2 @@ -0,0 +1,82 @@ +[% + WRAPPER "staff/base.tt2"; + ctx.page_title = l("Batch Hold Buckets"); + ctx.page_app = "egCatBatchHoldBuckets"; + ctx.page_ctrl = "BatchHoldBucketCtrl"; +%] + +[% BLOCK APP_JS %] + + + + + +[% END %] + + + + +
+
+ + +
+
+ [% INCLUDE 'staff/cat/bucket/batch_hold/t_bucket_info.tt2' %] +
+
+ + +
+
+ [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %] +
+
+ +
+
+
+ +[% END %] diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_bucket_info.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_bucket_info.tt2 new file mode 100644 index 0000000000..c3203970ac --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_bucket_info.tt2 @@ -0,0 +1,21 @@ + +
+ [% l('Subscription #{{bucket().id()}}: {{bucket().name()}}') %] + / {{bucket().description()}} +
+ + + + + / [% l('Created {{bucket().create_time() | date:egDateAndTimeFormat}}') %] + / + {{bucket()._owner_name}} + @ {{bucket()._owner_ou}} + +
+ +
+ [% l('No Subscription Selected') %] +
+ diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event.tt2 new file mode 100644 index 0000000000..ebecad66d2 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event.tt2 @@ -0,0 +1,42 @@ + + + + + + + + + + + {{item['title']}} + + + {{item['author']}} + + + + {{item['mappings'].length}} + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event_create.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event_create.tt2 new file mode 100644 index 0000000000..bf44fd0c6e --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event_create.tt2 @@ -0,0 +1,30 @@ + + + + +
+ + + +
+ diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_grid_menu.tt2 new file mode 100644 index 0000000000..cd3d693cd9 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_grid_menu.tt2 @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_list.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_list.tt2 new file mode 100644 index 0000000000..b2de6a0f25 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_list.tt2 @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_pending.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_pending.tt2 new file mode 100644 index 0000000000..81b83afe39 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_pending.tt2 @@ -0,0 +1,58 @@ +
+
+
+
+ [% l('Scan Barcode') %] + +
+ +
+
+
+ +
+
+ +
+
+
+ [% l('User Not Found') %] +
+
+
+ +
+ + + + + + + + + + + + + + + + + + {{item['card.barcode']}} + + + + diff --git a/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_view.tt2 b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_view.tt2 new file mode 100644 index 0000000000..240b1728bd --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_view.tt2 @@ -0,0 +1,29 @@ + + + [% INCLUDE 'staff/cat/bucket/batch_hold/t_grid_menu.tt2' %] + + + + + + + + + + + + {{item['card.barcode']}} + + + + diff --git a/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_create.tt2 index 210ab8f59d..5e05e72486 100644 --- a/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_create.tt2 +++ b/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_create.tt2 @@ -19,10 +19,15 @@ -
+
+ + +
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_edit.tt2 b/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_edit.tt2 index 81b8cf6367..42fcf4e141 100644 --- a/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_edit.tt2 +++ b/Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_edit.tt2 @@ -17,10 +17,15 @@ +
+ + +
diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2 index a36c758953..cd2b94b524 100644 --- a/Open-ILS/src/templates/staff/circ/patron/index.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2 @@ -78,6 +78,7 @@ angular.module('egCoreMod').run(['egStrings', function(s) { s.PAGE_TITLE_PATRON_ITEMS_OUT = "[% l('Items Out') %]"; s.PAGE_TITLE_PATRON_EDIT = "[% l('Edit') %]"; s.MERGE_SELF_NOT_ALLOWED = "[% l('Logged in account cannot be merged') %]" + s.REMOVE_HOLD_SUBSCRIPTIONS = "[% l('Remove selected Hold Subscriptions for user?') %]" }]); @@ -192,6 +193,11 @@ angular.module('egCoreMod').run(['egStrings', function(s) {
  • + + [% l('Hold Subscriptions') %] + +
  • +
  • [% l('Surveys') %] diff --git a/Open-ILS/src/templates/staff/circ/patron/t_hold_subscriptions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_hold_subscriptions.tt2 new file mode 100644 index 0000000000..8ec23a1479 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_hold_subscriptions.tt2 @@ -0,0 +1,21 @@ +
    [% l('Hold Subscriptions') %]
    + + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 index f42ba4a596..909215b7bb 100644 --- a/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 @@ -23,6 +23,10 @@ label="{{bkt.name()}}" handler-data="bkt" handler="addToBucket" disabled="need_one_selected"> + + {{item.card().barcode()}} diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2 index 1028f42bb7..e77059e279 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -173,6 +173,12 @@ [% l('User Buckets') %]
  • +
  • + + + [% l('Hold Subscriptions') %] + +
  • diff --git a/Open-ILS/web/js/ui/default/opac/staff.js b/Open-ILS/web/js/ui/default/opac/staff.js index d794f61773..525b8d5a57 100644 --- a/Open-ILS/web/js/ui/default/opac/staff.js +++ b/Open-ILS/web/js/ui/default/opac/staff.js @@ -26,6 +26,7 @@ function staff_hold_usr_input_disabler(input) { Boolean(Number(input.value)); staff_hold_usr_barcode_changed(); } + var debounce_barcode_change = function() { var timeout; @@ -44,6 +45,42 @@ var debounce_barcode_change = function() { return true; }; }(); + +function no_hold_submit(event) { + if (event.which == 13) { + staff_hold_usr_barcode_changed(); + return false; + } + return true; +} + +function toggleMROptions(on) { + var anchor = document.getElementById("advanced_hold_link"); + // Check for not equal to block so it works on first click. + if (on) { + anchor.style.display = "inline"; + } else { + anchor.style.display = "none"; + } +} + +function maybeDisable (thing, value) { + var el = document.getElementById(thing); + if (el) el.disabled = value; +} + +function toggleOnSubscription(isSub) { + toggleMROptions(!isSub); + maybeDisable("override_blocks_subscription",!isSub); + maybeDisable("pickup_lib",isSub); + maybeDisable("email_notify",isSub); + maybeDisable("phone_notify_checkbox",isSub); + maybeDisable("phone_notify",isSub); + maybeDisable("sms_notify_checkbox",isSub); + maybeDisable("sms_carrier",isSub); + maybeDisable("sms_notify",isSub); +} + function staff_hold_usr_barcode_changed(isload) { if (!document.getElementById('place_hold_submit')) { @@ -56,8 +93,13 @@ function staff_hold_usr_barcode_changed(isload) { var adv_link = document.getElementById('advanced_hold_link'); if (adv_link) { - adv_link.setAttribute('href', adv_link.getAttribute('href').replace(/&?is_requestor=[01]/,'')); - var is_requestor = document.getElementById('hold_usr_is_requestor').checked ? 1 : 0; + adv_link.setAttribute('href', adv_link.getAttribute('href').replace(/&?is_requestor=[012]/,'')); + var is_requestor = 0; + if (document.getElementById('hold_usr_is_requestor').checked) { + is_requestor = 1; + } else if (document.getElementById('hold_usr_is_subscription').checked) { + is_requestor = 2; + } adv_link.setAttribute('href', adv_link.getAttribute('href') + '&is_requestor=' + is_requestor.toString()); } @@ -65,7 +107,15 @@ function staff_hold_usr_barcode_changed(isload) { var barcode = isload; if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value; var only_settings = true; - if(!document.getElementById('hold_usr_is_requestor').checked) { + var sub_el = document.getElementById('hold_usr_is_subscription'); + + toggleOnSubscription(false); + if(sub_el && sub_el.checked) { + toggleOnSubscription(true); + if(!isload) { + only_settings = false; + } + } else if(!document.getElementById('hold_usr_is_requestor').checked) { if(!isload) { barcode = document.getElementById('hold_usr_input').value; only_settings = false; @@ -73,7 +123,8 @@ function staff_hold_usr_barcode_changed(isload) { if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked) document.getElementById('hold_usr_is_requestor_not').checked = 'checked'; } - if(barcode == undefined || barcode == '') { + + if((barcode == undefined || barcode == '') && (!sub_el || !sub_el.checked)) { document.getElementById('patron_name').innerHTML = ''; // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either document.getElementById('place_hold_submit').disabled = true; @@ -113,6 +164,8 @@ function staff_hold_usr_barcode_changed(isload) { function staff_hold_usr_barcode_changed2( isload, only_settings, barcode, cur_hold_barcode, load_info) { + var sub_el = document.getElementById('hold_usr_is_subscription'); + if(load_info == false || load_info == undefined) { document.getElementById('patron_name').innerHTML = ''; document.getElementById("patron_usr_barcode_not_found").style.display = ''; @@ -120,7 +173,7 @@ function staff_hold_usr_barcode_changed2( return; } cur_hold_barcode = load_info.barcode; - if (!only_settings || (isload && isload !== true)) { + if ((!only_settings || (isload && isload !== true)) && (sub_el && !sub_el.checked)) { // Safe at this point as we already set cur_hold_barcode document.getElementById('hold_usr_input').value = load_info.barcode; @@ -138,35 +191,37 @@ function staff_hold_usr_barcode_changed2( load_info.settings['opac.default_sms_carrier'] = ''; } - if (load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') { - var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1; - var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1; - var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1; - var update_elements = document.getElementsByName('email_notify'); - for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : ''); - update_elements = document.getElementsByName('phone_notify_checkbox'); - for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : ''); - update_elements = document.getElementsByName('sms_notify_checkbox'); - for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : ''); - } - - update_elements = document.getElementsByName('phone_notify'); - for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'] - ? load_info.settings['opac.default_phone'] : ''; - update_elements = document.getElementsByName('sms_notify'); - for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify']; - update_elements = document.getElementsByName('sms_carrier'); - for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier']; - update_elements = document.getElementsByName('email_notify'); - for(var i in update_elements) { - update_elements[i].disabled = (load_info.user_email ? false : true); - if(update_elements[i].disabled) update_elements[i].checked = false; - } - update_elements = document.getElementsByName('email_address'); - for(var i in update_elements) update_elements[i].textContent = load_info.user_email; - if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) { - document.getElementById('patron_name').innerHTML = load_info.patron_name; - document.getElementById("patron_usr_barcode_not_found").style.display = 'none'; + if (!sub_el || !sub_el.checked) { + if (load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') { + var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1; + var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1; + var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1; + var update_elements = document.getElementsByName('email_notify'); + for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : ''); + update_elements = document.getElementsByName('phone_notify_checkbox'); + for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : ''); + update_elements = document.getElementsByName('sms_notify_checkbox'); + for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : ''); + } + + update_elements = document.getElementsByName('phone_notify'); + for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'] + ? load_info.settings['opac.default_phone'] : ''; + update_elements = document.getElementsByName('sms_notify'); + for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify']; + update_elements = document.getElementsByName('sms_carrier'); + for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier']; + update_elements = document.getElementsByName('email_notify'); + for(var i in update_elements) { + update_elements[i].disabled = (load_info.user_email ? false : true); + if(update_elements[i].disabled) update_elements[i].checked = false; + } + update_elements = document.getElementsByName('email_address'); + for(var i in update_elements) update_elements[i].textContent = load_info.user_email; + if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) { + document.getElementById('patron_name').innerHTML = load_info.patron_name; + document.getElementById("patron_usr_barcode_not_found").style.display = 'none'; + } } // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active @@ -189,12 +244,15 @@ window.onload = function() { setTimeout(function() { - if (location.href.match(/is_requestor=[01]/)) { + if (location.href.match(/is_requestor=[012]/)) { var loc = location.href; - var is_req_match = new RegExp("is_requestor=[01]"); + var is_req_match = new RegExp("is_requestor=[012]"); var is_req = is_req_match.exec(loc).toString(); is_req = is_req.replace(/is_requestor=/, ''); - if (is_req == "1") { + if (is_req == "2") { + document.getElementById('hold_usr_is_subscription').checked = 'checked'; + document.getElementById('hold_usr_input').disabled = true; + } else if (is_req == "1") { document.getElementById('hold_usr_is_requestor').checked = 'checked'; document.getElementById('hold_usr_input').disabled = true; } else { diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/batch_hold/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/batch_hold/app.js new file mode 100644 index 0000000000..cf74d044ce --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/batch_hold/app.js @@ -0,0 +1,889 @@ +/** + * Batch Hold (user) Buckets + * + * Known Issues + * + * add-all actions only add visible/fetched items. + * remove all from bucket UI leaves busted pagination + * -- apply a refresh after item removal? + * problems with bucket view fetching by record ID instead of bucket item: + * -- dupe bibs always sort to the bottom + * -- dupe bibs result in more records displayed per page than requested + * -- item 'pos' ordering is not honored on initial load. + */ + +angular.module('egCatBatchHoldBuckets', + ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'egPatronSearchMod']) + +.config(function($routeProvider, $locationProvider, $compileProvider) { + $locationProvider.html5Mode(true); + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|mailto|blob):/); // grid export + + var resolver = {delay : function(egStartup) {return egStartup.go()}}; + + $routeProvider.when('/cat/bucket/batch_hold/pending/:id', { + templateUrl: './cat/bucket/batch_hold/t_pending', + controller: 'PendingCtrl', + resolve : resolver + }); + + $routeProvider.when('/cat/bucket/batch_hold/pending', { + templateUrl: './cat/bucket/batch_hold/t_pending', + controller: 'PendingCtrl', + resolve : resolver + }); + + $routeProvider.when('/cat/bucket/batch_hold/view/:id', { + templateUrl: './cat/bucket/batch_hold/t_view', + controller: 'ViewCtrl', + resolve : resolver + }); + + $routeProvider.when('/cat/bucket/batch_hold/view', { + templateUrl: './cat/bucket/batch_hold/t_view', + controller: 'ViewCtrl', + resolve : resolver + }); + + $routeProvider.when('/cat/bucket/batch_hold/list', { + templateUrl: './cat/bucket/batch_hold/t_list', + controller: 'ListCtrl', + resolve : resolver + }); + + $routeProvider.when('/cat/bucket/batch_hold/event/:id', { + templateUrl: './cat/bucket/batch_hold/t_event', + controller: 'BucketEventCtrl', + resolve : resolver + }); + + // default page / bucket view + $routeProvider.otherwise({redirectTo : '/cat/bucket/batch_hold/list'}); +}) + +.config(['ngToastProvider', function(ngToastProvider) { + ngToastProvider.configure({ + verticalPosition: 'bottom', + animation: 'fade' + }); +}]) + +/** + * bucketSvc allows us to communicate between the pending + * and view controllers. It also allows us to cache + * data for each so that data reloads are not needed on every + * tab click (i.e. route persistence). + */ +.factory('bucketSvc', ['$q','egCore', function($q, egCore) { + + var service = { + allBuckets : [], // un-fleshed user buckets + barcodeString : '', // last scanned barcode + barcodeRecords : [], // last scanned barcode results + currentBucket : null, // currently viewed bucket + + // for informational purposes + eventList : [], + + // per-page list collections + pendingList : [], + viewList : [], + + // fetches all staff/batch_hold buckets for the authenticated user + // this function may only be called after startup. + fetchUserBuckets : function(force) { + if (this.allBuckets.length && !force) return; + var self = this; + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.retrieve_by_class.authoritative', + egCore.auth.token(), egCore.auth.user().id(), + 'user', 'hold_subscription' + ).then(function(buckets) { self.allBuckets = buckets }); + }, + + createBucket : function(name, desc, owning_lib, pub) { + var deferred = $q.defer(); + var bucket = new egCore.idl.cub(); + bucket.owner(egCore.auth.user().id()); + bucket.name(name); + bucket.pub(pub); + bucket.description(desc || ''); + bucket.btype('hold_subscription'); + bucket.owning_lib(owning_lib.id()); + + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.create', + egCore.auth.token(), 'user', bucket + ).then(function(resp) { + if (resp) { + if (typeof resp == 'object') { + console.error('bucket create error: ' + js2JSON(resp)); + deferred.reject(); + } else { + deferred.resolve(resp); + } + } + }); + + return deferred.promise; + }, + + // edit the current bucket. since we edit the + // local object, there's no need to re-fetch. + editBucket : function(args) { + var bucket = service.currentBucket; + bucket.name(args.name); + bucket.description(args.desc); + bucket.pub(args.pub); + if (args.owning_lib) { + if (typeof args.owning_lib == 'object') bucket.owning_lib(args.owning_lib.id()); + else bucket.owning_lib(args.owning_lib); + } + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.update', + egCore.auth.token(), 'user', bucket + ); + } + } + + // returns 1 if full refresh is needed + // returns 2 if list refresh only is needed + service.bucketRefreshLevel = function(id) { + if (!service.currentBucket) return 1; + if (service.bucketNeedsRefresh) { + service.bucketNeedsRefresh = false; + service.currentBucket = null; + return 1; + } + if (service.currentBucket.id() != id) return 1; + return 2; + } + + // returns a promise, resolved with bucket, rejected if bucket is + // not fetch-able + service.fetchBucket = function(id) { + var refresh = service.bucketRefreshLevel(id); + if (refresh == 2) return $q.when(service.currentBucket); + + var deferred = $q.defer(); + + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.flesh.authoritative', + egCore.auth.token(), 'user', id + ).then(function(bucket) { + var evt = egCore.evt.parse(bucket); + if (evt) { + console.debug(evt); + deferred.reject(evt); + return; + } + + if (typeof bucket.owning_lib != 'object') { + if (bucket.owning_lib()) { + bucket.owning_lib(egCore.org.get(bucket.owning_lib())); + } else { + bucket.owning_lib(egCore.org.get(egCore.auth.user().ws_ou())); + } + } + + egCore.pcrud.retrieve( + 'au', bucket.owner(), + {flesh : 1, flesh_fields : {au : ["card"]}} + ).then(function(patron) { + // On the off chance no barcode is present (it's not + // required) use the patron username as the identifier. + bucket._owner_ident = patron.card() ? + patron.card().barcode() : patron.usrname(); + bucket._owner_name = patron.family_name(); + bucket._owner_ou = bucket.owning_lib().shortname(); + }); + + service.currentBucket = bucket; + deferred.resolve(bucket); + }); + + return deferred.promise; + } + + // deletes a single container item from a bucket by container item ID. + // promise is rejected on failure + service.detachUser = function(itemId) { + var deferred = $q.defer(); + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.item.delete', + egCore.auth.token(), 'user', itemId + ).then(function(resp) { + var evt = egCore.evt.parse(resp); + if (evt) { + console.error(evt); + deferred.reject(evt); + return; + } + console.log('detached bucket item ' + itemId); + deferred.resolve(resp); + }); + + return deferred.promise; + } + + // delete bucket by ID. + // resolved w/ response on successful delete, + // rejected otherwise. + service.deleteBucket = function(id) { + var deferred = $q.defer(); + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.full_delete', + egCore.auth.token(), 'user', id + ).then(function(resp) { + var evt = egCore.evt.parse(resp); + if (evt) { + console.error(evt); + deferred.reject(evt); + return; + } + deferred.resolve(resp); + }); + return deferred.promise; + } + + return service; +}]) + +/** + * Top-level controller. + * Hosts functions needed by all controllers. + */ +.controller('BatchHoldBucketCtrl', + ['$scope','$location','$q','$timeout','$uibModal', + '$window','egCore','bucketSvc', +function($scope, $location, $q, $timeout, $uibModal, + $window, egCore, bucketSvc) { + + $scope.bucketSvc = bucketSvc; + $scope.bucket = function() { return bucketSvc.currentBucket } + + // tabs: search, pending, view + $scope.setTab = function(tab) { + $scope.tab = tab; + + // for bucket selector; must be called after route resolve + bucketSvc.fetchUserBuckets(); + }; + + $scope.loadBucketFromMenu = function(item, bucket) { + if (bucket) return $scope.loadBucket(bucket.id()); + } + + $scope.loadBucket = function(id) { + $location.path( + '/cat/bucket/batch_hold/' + + $scope.tab + '/' + encodeURIComponent(id)); + } + + $scope.addToBucket = function(recs) { + if (recs.length == 0) return; + bucketSvc.bucketNeedsRefresh = true; + + angular.forEach(recs, + function(rec) { + var item = new egCore.idl.cubi(); + item.bucket(bucketSvc.currentBucket.id()); + item.target_user(rec.id); + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.item.create', + egCore.auth.token(), 'user', item, 1 + ).then(function(resp) { + + // HACK: add the IDs of the added items so that the size + // of the view list will grow (and update any UI looking at + // the list size). The data stored is inconsistent, but since + // we are forcing a bucket refresh on the next rendering of + // the view pane, the list will be repaired. + bucketSvc.currentBucket.items().push(resp); + }); + } + ); + } + + $scope.openCreateBucketDialog = function() { + $uibModal.open({ + templateUrl: './cat/bucket/share/t_bucket_create', + backdrop: 'static', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = true; + $scope.change_owner = true; + $scope.args = {}; + $scope.args.owning_lib = egCore.org.get(egCore.auth.user().ws_ou()); + $scope.args.hold_sub = true; + $scope.ok = function(args) { $uibModalInstance.close(args) } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }] + }).result.then(function (args) { + if (!args || !args.name) return; + bucketSvc.createBucket(args.name, args.desc, args.owning_lib, args.pub).then( + function(id) { + if (!id) return; + bucketSvc.viewList = []; + bucketSvc.allBuckets = []; // reset + bucketSvc.currentBucket = null; + $location.path( + '/cat/bucket/batch_hold/' + $scope.tab + '/' + id); + } + ); + }); + } + + $scope.openEditBucketDialog = function() { + $uibModal.open({ + templateUrl: './cat/bucket/share/t_bucket_edit', + backdrop: 'static', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = true; + $scope.change_owner = true; + $scope.args = { + hold_sub : true, + name : bucketSvc.currentBucket.name(), + desc : bucketSvc.currentBucket.description(), + pub : bucketSvc.currentBucket.pub() == 't', + owning_lib : bucketSvc.currentBucket.owning_lib() || egCore.org.get(egCore.auth.user().ws_ou()) + }; + $scope.ok = function(args) { + if (!args) return; + $scope.actionPending = true; + args.pub = args.pub ? 't' : 'f'; + // close the dialog after edit has completed + bucketSvc.bucketNeedsRefresh = true; + bucketSvc.editBucket(args).then( + function() { $uibModalInstance.close(); bucketSvc.fetchBucket(bucketSvc.currentBucket.id()) }); + } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }] + }) + } + + // opens the delete confirmation and deletes the current + // bucket if the user confirms. + $scope.openDeleteBucketDialog = function() { + $uibModal.open({ + templateUrl: './cat/bucket/share/t_bucket_delete', + backdrop: 'static', + controller : + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.bucket = function() { return bucketSvc.currentBucket } + $scope.ok = function() { $uibModalInstance.close() } + $scope.cancel = function() { $uibModalInstance.dismiss() } + }] + }).result.then(function () { + bucketSvc.deleteBucket(bucketSvc.currentBucket.id()) + .then(function() { + bucketSvc.allBuckets = []; + $location.path('/cat/bucket/batch_hold/view'); + }); + }); + } + + // retrieves the requested bucket by ID + $scope.openSharedBucketDialog = function() { + $uibModal.open({ + templateUrl: './cat/bucket/share/t_load_shared', + backdrop: 'static', + controller : + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = true; + $scope.ok = function(args) { + if (args && args.id) { + $uibModalInstance.close(args.id) + } + } + $scope.cancel = function() { $uibModalInstance.dismiss() } + }] + }).result.then(function(id) { + // RecordBucketCtrl $scope is not inherited by the + // modal, so we need to call loadBucket from the + // promise resolver. + $scope.loadBucket(id); + }); + } + +}]) + +.controller('PendingCtrl', + ['$scope','$routeParams','bucketSvc','egGridDataProvider', 'egCore','$uibModal', +function($scope, $routeParams, bucketSvc , egGridDataProvider, egCore, $uibModal) { + $scope.setTab('pending'); + + $scope.context = { + copyNotFound : false, + selectPendingBC : true + }; + + var query; + $scope.gridControls = { + setQuery : function(q) { + if (bucketSvc.pendingList.length) + return {id : bucketSvc.pendingList}; + else + return null; + }, + allItemsRetrieved : function() { + $scope.context.selectPendingBC = true; + } + } + + + $scope.addAllPending = function() { + $scope.addToBucket($scope.gridControls.allItems()); + $scope.resetPendingList(); + } + + $scope.handle_barcode_completion = function(barcode) { + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + egCore.auth.token(), egCore.auth.user().ws_ou(), + 'actor', barcode) + + .then(function(resp) { + // TODO: handle event during barcode lookup + if (evt = egCore.evt.parse(resp)) { + console.error(evt.toString()); + return $q.reject(); + } + + // no matching barcodes: return the barcode as entered + // by the user (so that, e.g., checkout can fall back to + // precat/noncat handling) + if (!resp || !resp[0]) { + return barcode; + } + + // exactly one matching barcode: return it + if (resp.length == 1) { + return resp[0].barcode; + } + + // multiple matching barcodes: let the user pick one + console.debug('multiple matching barcodes'); + var matches = []; + var promises = []; + var final_barcode; + angular.forEach(resp, function(usr) { + promises.push( + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.user.fleshed.retrieve_by_barcode', + egCore.auth.token(), usr.barcode + ).then(function(r) { + matches.push({ + barcode: r.card.barcode(), + title: r.last_given_name() + ', ' + r.first_given_name(), + org_name: egCore.org.get(r.home_ou()).name(), + org_shortname: egCore.org.get(r.home_ou()).shortname() + }); + }) + ); + }); + return $q.all(promises) + .then(function() { + return $uibModal.open({ + templateUrl: './circ/share/t_barcode_choice_dialog', + controller: + ['$scope', '$uibModalInstance', + function($scope, $uibModalInstance) { + $scope.matches = matches; + $scope.ok = function(barcode) { + $uibModalInstance.close(); + final_barcode = barcode; + } + $scope.cancel = function() {$uibModalInstance.dismiss()} + }], + }).result.then(function() { return final_barcode }); + }) + }); + } + + $scope.search = function() { + bucketSvc.barcodeRecords = []; + $scope.context.itemNotFound = false; + + // clear selection so re-selecting can have an effect + $scope.context.selectPendingBC = false; + + return $scope.handle_barcode_completion(bucketSvc.barcodeString) + .then(function(actual_barcode) { + egCore.pcrud.search( + 'ac', + {barcode : actual_barcode}, + {} + ).then(function(card) { + if (card) { + bucketSvc.pendingList.push(card.usr()); + $scope.gridControls.setQuery({id : bucketSvc.pendingList}); + bucketSvc.barcodeString = ''; // clear form on valid usr + } else { + $scope.context.itemNotFound = true; + $scope.context.selectPendingBC = true; + } + }); + }); + } + + $scope.patron_search_dialog = function() { + return $uibModal.open({ + templateUrl: './share/t_patron_selector', + backdrop: 'static', + size: 'lg', + animation: true, + controller: + ['$scope','$uibModalInstance','$controller', + function($scope , $uibModalInstance , $controller) { + angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope})); + $scope.clearForm(); + $scope.need_one_selected = function() { + var items = $scope.gridControls.selectedItems(); + return (items.length == 1) ? false : true + } + $scope.ok = function() { + var items = $scope.gridControls.selectedItems(); + if (items.length == 1) { + $uibModalInstance.close(items[0].card().barcode()); + } else { + $uibModalInstance.close() + } + } + $scope.cancel = function($event) { + $uibModalInstance.dismiss(); + $event.preventDefault(); + } + }] + }).result.then(function(bc) { + bucketSvc.barcodeString = bc; + if (bc) $scope.search(); + }); + } + + $scope.resetPendingList = function() { + bucketSvc.pendingList = []; + $scope.gridControls.setQuery({}); + } + + if ($routeParams.id && + (!bucketSvc.currentBucket || + bucketSvc.currentBucket.id() != $routeParams.id)) { + // user has accessed this page cold with a bucket ID. + // fetch the bucket for display, then set the totalCount + // (also for display), but avoid fully fetching the bucket, + // since it's premature, in this UI. + bucketSvc.fetchBucket($routeParams.id); + } + $scope.gridControls.setQuery(); +}]) + +.controller('ViewCtrl', + ['$scope','$q','$routeParams','$timeout','$window','$uibModal','bucketSvc','egCore','egUser', + 'egConfirmDialog', +function($scope, $q , $routeParams , $timeout , $window , $uibModal , bucketSvc , egCore , egUser , + egConfirmDialog) { + + $scope.setTab('view'); + $scope.bucketId = $routeParams.id; + + var query; + $scope.gridControls = { + setQuery : function(q) { + if (q) query = q; + return query; + } + }; + + function drawBucket() { + return bucketSvc.fetchBucket($scope.bucketId).then( + function(bucket) { + var ids = bucket.items().map( + function(i){return i.target_user()} + ); + if (ids.length) { + $scope.gridControls.setQuery({id : ids}); + } else { + $scope.gridControls.setQuery({}); + } + } + ); + } + + $scope.detachUsers = function(users) { + var promises = []; + angular.forEach(users, function(rec) { + var item = bucketSvc.currentBucket.items().filter( + function(i) { + return (i.target_user() == rec.id) + } + ); + if (item.length) + promises.push(bucketSvc.detachUser(item[0].id())); + }); + + bucketSvc.bucketNeedsRefresh = true; + return $q.all(promises).then(drawBucket); + } + + $scope.moveToPending = function(users) { + angular.forEach(users, function(usr) { + bucketSvc.pendingList.push(usr.id); + }); + $scope.detachUsers(users); + } + + + // fetch the bucket; on error show the not-allowed message + if ($scope.bucketId) + drawBucket()['catch'](function() { $scope.forbidden = true }); +}]) + +.controller('BucketEventCtrl', + ['$scope','$q','$routeParams','$timeout','$window','$uibModal','bucketSvc','egCore','egUser', + 'egConfirmDialog','egProgressDialog', 'ngToast', '$interpolate', +function($scope, $q , $routeParams , $timeout , $window , $uibModal , bucketSvc , egCore , egUser , + egConfirmDialog, egProgressDialog , ngToast , $interpolate) { + + $scope.setTab('event'); + $scope.bucketId = $routeParams.id; + $scope.eventList = []; + $scope.failedPatronList = []; + + var query; + $scope.gridControls = { + setSort : function() { + return [{run_date : 'desc'}]; + }, + setQuery : function(q) { + if (q) query = q; + return query; + }, + itemRetrieved : function (item) { + item.mappings = []; + egCore.pcrud.retrieve( + 'mwde', item.target + ).then(function (wide) { + item.title = angular.fromJson(wide.title()); + item.author = angular.fromJson(wide.author()); + }).then(function () { + egCore.pcrud.search( + 'abhem', {batch_hold_event : item.id} + ).then(null,null,function (m) { + if (m) item.mappings.push(m); + }); + }); + } + }; + + function drawEventList() { + $scope.gridControls.setQuery({bucket : $scope.bucketId}); + } + + $scope.rollbackEvent = function (items) { + egConfirmDialog.open( + egCore.strings.EVENT_ROLLBACK_TITLE, '', {} + ).result.then(function() { + var promises = []; + egProgressDialog.open({max : 1, value : 0}); + angular.forEach(items, function (item) { + promises.push( + egCore.net.request( + 'open-ils.circ', + 'open-ils.circ.holds.rollback.subscription_batch', + egCore.auth.token(), item.id + ).then( + null, + null, + function(res) { // each + egProgressDialog.update({ + max : res.total, + value : res.count + }); + } + ) + ) + }); + + $q.all(promises).finally(function() { + egProgressDialog.close(); + drawEventList(); + }); + }); + + } + + $scope.openCreateEventDialog = function () { + var outer_scope = $scope; + $uibModal.open({ + templateUrl: './cat/bucket/batch_hold/t_event_create', + backdrop: 'static', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = true; + $scope.args = { target: null, override: true }; + $scope.ok = function(args) { $uibModalInstance.close(args) } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }] + }).result.then(function (args) { + outer_scope.failedPatronList = []; + + if (!args || !args.target) { + ngToast.warning(egCore.strings.EVENT_NO_TARGET); + return; + } + + var method = 'open-ils.circ.holds.test_and_create.subscription_batch'; + if (args.override) method += '.override'; + + var success_count = 0; + var total_count = -1; // we throw away the first result, which just gives us the max + egProgressDialog.open({max : 1, value : 0}); + egCore.net.request( + 'open-ils.circ', method, + egCore.auth.token(), {pickup_lib : egCore.auth.user().ws_ou()}, + bucketSvc.currentBucket.id(), args.target + ).then( + null, + null, + function(res) { // each + if (res.error && res.error == 'invalid_target') { + ngToast.warning(egCore.strings.EVENT_INVALID_TARGET); + return; + } else { + total_count++; + } + egProgressDialog.update({ + max : res.total, + value : res.count + }); + if (res.patronid) { + success_count++; + } else if (res.failedpatronid) { + outer_scope.failedPatronList.push(res.failedpatronid); + } + } + ).finally(function() { + if (total_count > 0) { + ngToast.create( + $interpolate(egCore.strings.EVENT_CREATE_SUMMARY)( + {success:success_count,total:total_count} + ) + ); + } + egProgressDialog.close(); + drawEventList(); + }) + }); + } + + /** Export the failed patron list as CSV. + * Flow of events: + * 1. User clicks the 'download patrons' link + * 2. All patrons (cards) are retrieved asychronously + * 3. Once all data is all present and CSV-ized, the download + * attributes are linked to the href. + * 4. The href .click() action is prgrammatically fired again, + * telling the browser to download the data, now that the + * data is available for download. + * 5 Once downloaded, the href attributes are reset. + */ + $scope.csvExportURL = ''; + $scope.csvExportFileName = ''; + $scope.csvExportInProgress = false; + $scope.downloadFailed = function($event) { + + if ($scope.csvExportInProgress) { + // This is secondary href click handler. Give the + // browser a moment to start the download, then reset + // the CSV download attributes / state. + $timeout( + function() { + $scope.csvExportURL = ''; + $scope.csvExportFileName = ''; + $scope.csvExportInProgress = false; + }, 500 + ); + return; + } + + $scope.csvExportInProgress = true; + + // let the file name describe the grid + $scope.csvExportFileName = 'failed_hold_patrons'; + + var list_text = ''; + egCore.pcrud.search( + 'au', + {id : $scope.failedPatronList}, + {flesh : 1, flesh_fields : {au : ["card"]}} + ).then( + function() { + var blob = new Blob([list_text], {type : 'text/plain'}); + $scope.csvExportURL = + ($window.URL || $window.webkitURL).createObjectURL(blob); + + // Fire the 2nd click event now that the browser has + // information on how to download the CSV file. + $timeout(function() {$event.target.click()}); + },null, + function (u) { + list_text += u.card().barcode() + '\n'; + } + ); + } + + // fetch the bucket; on error show the not-allowed message + if ($scope.bucketId && + (!bucketSvc.currentBucket || + bucketSvc.currentBucket.id() != $scope.bucketId)) { + // user has accessed this page cold with a bucket ID. + // fetch the bucket for display, then set the totalCount + // (also for display), but avoid fully fetching the bucket, + // since it's premature, in this UI. + bucketSvc.fetchBucket($scope.bucketId).then(drawEventList); + } else { + $timeout(drawEventList); + } +}]) + +.controller('ListCtrl', + ['$scope','$q','$location','$timeout','$window','$uibModal','bucketSvc','egCore','egUser', + 'egConfirmDialog', +function($scope, $q , $location , $timeout , $window , $uibModal , bucketSvc , egCore , egUser , + egConfirmDialog) { + + $scope.setTab('list'); + + var query; + $scope.gridControls = { + setSort : function() { + return ['name']; + }, + setQuery : function(q) { + if (q) query = q; + return query; + }, + activateItem : function (item) { + $location.path( + '/cat/bucket/batch_hold/view/' + item.id ); + } + }; + + function drawList() { + $scope.gridControls.setQuery({btype : 'hold_subscription'}); + } + + $timeout(drawList); +}]) + diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js index a886195050..8270d04382 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -210,6 +210,12 @@ angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 'egUserBucketMod', resolve : resolver }); + $routeProvider.when('/circ/patron/:id/hold_subscriptions', { + templateUrl: './circ/patron/t_hold_subscriptions', + controller: 'HoldSubscriptionsCtrl', + resolve : resolver + }); + $routeProvider.otherwise({redirectTo : '/circ/patron/search'}); }) @@ -627,6 +633,7 @@ function($scope, $q, $routeParams, $timeout, $window, $location, egCore , $scope.bucketSvc = bucketSvc; $scope.bucketSvc.fetchUserBuckets(); + $scope.bucketSvc.fetchUserSubscriptions(); $scope.addToBucket = function(item, data, recs) { if (recs.length == 0) return; var added_count = 0; @@ -959,6 +966,82 @@ function($scope, $routeParams , $location , egCore , patronSvc) { }]) +.controller('HoldSubscriptionsCtrl', + ['$scope','$q','$routeParams','$location','egCore','patronSvc','bucketSvc','egGridDataProvider','egConfirmDialog','$timeout','$window', +function($scope, $q , $routeParams , $location , egCore , patronSvc, bucketSvc, egGridDataProvider, egConfirmDialog, $timeout, $window) { + + $scope.initTab('other', $routeParams.id); + + $scope.bucket_ids = []; + $scope.bucket_items = []; + $scope.buckets = []; + + $scope.gridControls = { + activateItem : function (item) { + var url = $location.absUrl().replace( + /\/circ\/patron\/.*/, + '/cat/bucket/batch_hold/view/' + item.id()); + $window.open(url, '_blank').focus(); + } + }; + + $scope.gridDataProvider = egGridDataProvider.instance({ + get : function(offset, count) { + return this.arrayNotifier($scope.buckets, offset, count); + } + }); + + function fetchSubscriptions() { + $scope.bucket_ids = []; + $scope.bucket_items = []; + $scope.buckets = []; + egCore.pcrud.search('cubi', + { target_user : $routeParams.id } + ).then( + function() { + if ($scope.bucket_ids.length > 0) { + egCore.pcrud.search('cub', + { id : $scope.bucket_ids, btype : 'hold_subscription' } + ).then( + function() { $scope.gridControls.refresh() }, + null, + function(b) { + $scope.buckets.push(b); + b.items( $scope.bucket_items.filter(i => i.bucket() == b.id()) ); + } + ); + } else { + $scope.gridControls.refresh(); + } + }, + null, + function(i) { + $scope.bucket_ids.push(i.bucket()); + $scope.bucket_items.push(i); + } + ) + } + + $scope.removeSubscriptions = function (buckets) { + return egConfirmDialog.open( + egCore.strings.REMOVE_HOLD_SUBSCRIPTIONS,'',{} + ).result.then(function() { + var promises = []; + + angular.forEach(buckets, function(b) { + angular.forEach(b.items(), function (i) { + promises.push(bucketSvc.detachUser(i.id())); + }) + }); + + $q.all(promises).then(fetchSubscriptions); + }); + } + + $timeout(fetchSubscriptions); + +}]) + .controller('PatronNotesCtrl', ['$scope','$filter','$routeParams','$location','egCore','patronSvc','$uibModal', 'egConfirmDialog', diff --git a/Open-ILS/web/js/ui/default/staff/services/user-bucket.js b/Open-ILS/web/js/ui/default/staff/services/user-bucket.js index 487c385843..790dd793e9 100644 --- a/Open-ILS/web/js/ui/default/staff/services/user-bucket.js +++ b/Open-ILS/web/js/ui/default/staff/services/user-bucket.js @@ -6,8 +6,13 @@ angular.module('egUserBucketMod', ['egCoreMod']) .factory('bucketSvc', ['$q','egCore', function($q, egCore) { + function _sort_buckets(a,b) { + return a.name() < b.name() ? -1 : 1; + } + var service = { allBuckets : [], // un-fleshed user buckets + allSubscriptions : [], // un-fleshed user buckets for hold subscriptions barcodeString : '', // last scanned barcode barcodeRecords : [], // last scanned barcode results currentBucket : null, // currently viewed bucket @@ -29,6 +34,15 @@ angular.module('egUserBucketMod', ['egCoreMod']) ).then(function(buckets) { self.allBuckets = buckets }); }, + fetchUserSubscriptions : function(force) { + if (this.allSubscriptions.length && !force) return; + var self = this; + return egCore.pcrud.search( + 'cub', { btype : 'hold_subscription' }, + {}, { atomic : true } + ).then(function(buckets) { self.allSubscriptions = buckets.sort(_sort_buckets) }); + }, + createBucket : function(name, desc) { var deferred = $q.defer(); var bucket = new egCore.idl.cub(); -- 2.11.0