From 14af809466039334348ba38b8417fb462d57f55b Mon Sep 17 00:00:00 2001 From: Mike Rylander Date: Wed, 18 Jul 2018 20:52:25 -0400 Subject: [PATCH] LP#1712854: Speed improvements for two hold interfaces The Hold Shelf and Record -> View Holds interfaces are painfully slow when faced with a large set of holds. The main reason is the 2-stage process used to gather the data: 1) find hold IDs for the context 2) for each hold, fetch all the requisite data using a relatively slow API. Here we create a new API that provides a streaming response of pre-inflated hold data. The full result set is loaded immediately, and once loaded, the grid makes use of the "clientsort" feature to provide fast sorting without the need to go back to the server when only changing the sort columns and direction, and when paging through results. The Hold Shelf UI now has a progress indicator that appears whenever the hold list is retrieved from the server, and the progress indicator for the Record -> View Holds UI is enhanced to provide more granular updates. It is expected that other hold interfaces will be able to make use of this new API for performance and functionality improvements. NOTE: This includes an additional change required to protect localStorage from overlarge values when attempting to save the "last printed value" for future reprinting. Previously, when attempting to print a 2000+ hold list, localStorage throws an error and the print fails. Now the print will succeed, but the value is not stored for reprinting and a message is logged to the JS console. NOTE: The original development plan was base this a new API on a native Postgres materialized view, and provide a LISTEN/NOTIFY daemon to monitor for events that should trigger a refresh of that view. As it happens, the use of other new Postgres features (primarily the LATERAL join type) allows us to avoid that maintenance and run-time complication. Signed-off-by: Mike Rylander Signed-off-by: Kathy Lussier Conflicts: Open-ILS/web/js/ui/default/staff/services/hatch.js Signed-off-by: Bill Erickson --- .../lib/OpenILS/Application/Circ/Holds.pm | 43 +++ .../Application/Storage/Publisher/action.pm | 310 ++++++++++++++++++ .../templates/staff/cat/catalog/t_holds.tt2 | 199 ++++++++--- .../staff/circ/holds/t_shelf_list.tt2 | 185 ++++++++--- .../js/ui/default/staff/cat/catalog/app.js | 155 +++++---- .../web/js/ui/default/staff/circ/holds/app.js | 185 ++++++----- .../ui/default/staff/circ/services/holds.js | 111 +++++++ .../web/js/ui/default/staff/services/hatch.js | 12 +- 8 files changed, 967 insertions(+), 233 deletions(-) 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 b15de7cf58..457b8aa17c 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm @@ -3347,6 +3347,49 @@ sub all_rec_holds { return $resp; } +__PACKAGE__->register_method( + method => 'stream_wide_holds', + authoritative => 1, + stream => 1, + api_name => 'open-ils.circ.hold.wide_hash.stream' +); + +sub stream_wide_holds { + my($self, $client, $auth, $restrictions, $order_by, $limit, $offset) = @_; + + my $e = new_editor(authtoken=>$auth); + $e->checkauth or return $e->event; + $e->allowed('VIEW_HOLD') or return $e->event; + + my $st = OpenSRF::AppSession->create('open-ils.storage'); + my $req = $st->request( + 'open-ils.storage.action.live_holds.wide_hash', + $restrictions, $order_by, $limit, $offset + ); + + my $count = $req->recv; + if(!$count) { + return 0; + } + + if(UNIVERSAL::isa($count,"Error")) { + throw $count ($count->stringify); + } + + $count = $count->content; + + # Force immediate send of count response + my $mbc = $client->max_bundle_count; + $client->max_bundle_count(1); + $client->respond($count); + $client->max_bundle_count($mbc); + + while (my $hold = $req->recv) { + $client->respond($hold->content) if $hold->content; + } + + $client->respond_complete; +} diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/action.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/action.pm index e489e26593..e0e96f98a5 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/action.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/action.pm @@ -2115,4 +2115,314 @@ sub title_hold_capture { } +sub wide_hold_data { + my $self = shift; + my $client = shift; + my $restrictions = shift; # hashref of field restrictions {f1=>undef,f2=>[1,2,3],f3=>'foo',f4=>{not=>undef}} + my $order_by = shift; # arrayref of hashrefs of ORDER BY clause, [{field =>{dir=>'desc',nulls=>'last'}}] + my $limit = shift; + my $offset = shift; + + $order_by = [$order_by] if (ref($order_by) !~ /ARRAY/); + + $log->info('Received '. keys(%$restrictions) .' restrictions'); + return 0 unless (ref $restrictions and keys %$restrictions); + + # force this to either 'true' or 'false' + my $is_staff_request = delete($$restrictions{is_staff_request}) || 'false'; + $is_staff_request = 'false' if (!grep {$is_staff_request eq $_} qw/true false/); + $log->info('is_staff_request: '. $is_staff_request); + + my $select = <<" SQL"; +WITH + t_field AS (SELECT field FROM config.display_field_map WHERE name = 'title'), + a_field AS (SELECT field FROM config.display_field_map WHERE name = 'author') +SELECT h.id, h.request_time, h.capture_time, h.fulfillment_time, h.checkin_time, + h.return_time, h.prev_check_time, h.expire_time, h.cancel_time, h.cancel_cause, + h.cancel_note, h.target, h.current_copy, h.fulfillment_staff, h.fulfillment_lib, + h.request_lib, h.requestor, h.usr, h.selection_ou, h.selection_depth, h.pickup_lib, + h.hold_type, h.holdable_formats, h.phone_notify, h.email_notify, h.sms_notify, + h.sms_carrier, h.frozen, h.thaw_date, h.shelf_time, h.cut_in_line, h.mint_condition, + h.shelf_expire_time, h.current_shelf_lib, h.behind_desk, + + CASE WHEN h.cancel_time IS NOT NULL THEN 6 + WHEN h.frozen AND h.capture_time IS NULL THEN 7 + WHEN h.current_shelf_lib IS NOT NULL AND h.current_shelf_lib <> h.pickup_lib THEN 8 + WHEN h.fulfillment_time IS NOT NULL THEN 9 + WHEN h.current_copy IS NULL THEN 1 + WHEN h.capture_time IS NULL THEN 2 + WHEN cp.status = 6 THEN 3 + WHEN EXTRACT(EPOCH FROM COALESCE(NULLIF(BTRIM(hold_wait_time.value,'"'),''),'0 seconds')::INTERVAL) = 0 THEN 4 + WHEN NOW() + COALESCE(NULLIF(BTRIM(hold_wait_time.value,'"'),''),'0 seconds')::INTERVAL > NOW() THEN 5 + ELSE 5 + END AS hold_status, + + (h.cancel_time IS NOT NULL OR (h.current_shelf_lib IS NOT NULL AND h.current_shelf_lib <> h.pickup_lib)) AS clear_me, + + (h.usr <> h.requestor) AS is_staff_hold, + + cc.id AS cc_id, cc.label AS cc_label, + + pl.id AS pl_id, pl.parent_ou AS pl_parent_ou, pl.ou_type AS pl_ou_type, + pl.ill_address AS pl_ill_address, pl.holds_address AS pl_holds_address, + pl.mailing_address AS pl_mailing_address, pl.billing_address AS pl_billing_address, + pl.shortname AS pl_shortname, pl.name AS pl_name, pl.email AS pl_email, + pl.phone AS pl_phone, pl.opac_visible AS pl_opac_visible, pl.fiscal_calendar AS pl_fiscal_calendar, + + tr.id AS tr_id, tr.source_send_time AS tr_source_send_time, tr.dest_recv_time AS tr_dest_recv_time, + tr.target_copy AS tr_target_copy, tr.source AS tr_source, tr.dest AS tr_dest, tr.prev_hop AS tr_prev_hop, + tr.copy_status AS tr_copy_status, tr.persistant_transfer AS tr_persistant_transfer, + tr.prev_dest AS tr_prev_dest, tr.hold AS tr_hold, tr.cancel_time AS tr_cancel_time, + + notes.count AS note_count, + + u.id AS usr_id, u.card AS usr_card, u.profile AS usr_profile, u.usrname AS usr_usrname, + u.email AS usr_email, u.standing AS usr_standing, u.ident_type AS usr_ident_type, + u.ident_value AS usr_ident_value, u.ident_type2 AS usr_ident_type2, + u.ident_value2 AS usr_ident_value2, u.net_access_level AS usr_net_access_level, + u.photo_url AS usr_photo_url, u.prefix AS usr_prefix, u.first_given_name AS usr_first_given_name, + u.second_given_name AS usr_second_given_name, u.family_name AS usr_family_name, + u.suffix AS usr_suffix, u.alias AS usr_alias, u.day_phone AS usr_day_phone, + u.evening_phone AS usr_evening_phone, u.other_phone AS usr_other_phone, + u.mailing_address AS usr_mailing_address, u.billing_address AS usr_billing_address, + u.home_ou AS usr_home_ou, u.dob AS usr_dob, u.active AS usr_active, + u.master_account AS usr_master_account, u.super_user AS usr_super_user, + u.barred AS usr_barred, u.deleted AS usr_deleted, u.juvenile AS usr_juvenile, + u.usrgroup AS usr_usrgroup, u.claims_returned_count AS usr_claims_returned_count, + u.credit_forward_balance AS usr_credit_forward_balance, u.last_xact_id AS usr_last_xact_id, + u.alert_message AS usr_alert_message, u.create_date AS usr_create_date, + u.expire_date AS usr_expire_date, u.claims_never_checked_out_count AS usr_claims_never_checked_out_count, + u.last_update_time AS usr_last_update_time, + + CASE WHEN u.alias IS NOT NULL THEN + u.alias + ELSE + u.first_given_name + END AS usr_alias_or_first_given_name, + + CASE WHEN u.alias IS NOT NULL THEN + u.alias + ELSE + REGEXP_REPLACE(ARRAY_TO_STRING(ARRAY[ + COALESCE(u.family_name, ''), + COALESCE(u.suffix, ''), + ', ', + COALESCE(u.prefix, ''), + COALESCE(u.first_given_name, ''), + COALESCE(u.second_given_name, '') + ], ' '), E'\\s+,', ',') + END AS usr_alias_or_display_name, + + REGEXP_REPLACE(ARRAY_TO_STRING(ARRAY[ + COALESCE(u.family_name, ''), + COALESCE(u.suffix, ''), + ', ', + COALESCE(u.prefix, ''), + COALESCE(u.first_given_name, ''), + COALESCE(u.second_given_name, '') + ], ' '), E'\\s+,', ',') AS usr_display_name, + + uc.id AS ucard_id, uc.barcode AS ucard_barcode, uc.usr AS ucard_usr, uc.active AS ucard_active, + + ru.id AS rusr_id, ru.card AS rusr_card, ru.profile AS rusr_profile, ru.usrname AS rusr_usrname, + ru.email AS rusr_email, ru.standing AS rusr_standing, ru.ident_type AS rusr_ident_type, + ru.ident_value AS rusr_ident_value, ru.ident_type2 AS rusr_ident_type2, + ru.ident_value2 AS rusr_ident_value2, ru.net_access_level AS rusr_net_access_level, + ru.photo_url AS rusr_photo_url, ru.prefix AS rusr_prefix, ru.first_given_name AS rusr_first_given_name, + ru.second_given_name AS rusr_second_given_name, ru.family_name AS rusr_family_name, + ru.suffix AS rusr_suffix, ru.alias AS rusr_alias, ru.day_phone AS rusr_day_phone, + ru.evening_phone AS rusr_evening_phone, ru.other_phone AS rusr_other_phone, + ru.mailing_address AS rusr_mailing_address, ru.billing_address AS rusr_billing_address, + ru.home_ou AS rusr_home_ou, ru.dob AS rusr_dob, ru.active AS rusr_active, + ru.master_account AS rusr_master_account, ru.super_user AS rusr_super_user, + ru.barred AS rusr_barred, ru.deleted AS rusr_deleted, ru.juvenile AS rusr_juvenile, + ru.usrgroup AS rusr_usrgroup, ru.claims_returned_count AS rusr_claims_returned_count, + ru.credit_forward_balance AS rusr_credit_forward_balance, ru.last_xact_id AS rusr_last_xact_id, + ru.alert_message AS rusr_alert_message, ru.create_date AS rusr_create_date, + ru.expire_date AS rusr_expire_date, ru.claims_never_checked_out_count AS rusr_claims_never_checked_out_count, + ru.last_update_time AS rusr_last_update_time, + + ruc.id AS rucard_id, ruc.barcode AS rucard_barcode, ruc.usr AS rucard_usr, ruc.active AS rucard_active, + + cp.id AS cp_id, cp.circ_lib AS cp_circ_lib, cp.creator AS cp_creator, cp.call_number AS cp_call_number, + cp.editor AS cp_editor, cp.create_date AS cp_create_date, cp.edit_date AS cp_edit_date, + cp.copy_number AS cp_copy_number, cp.status AS cp_status, cp.location AS cp_location, + cp.loan_duration AS cp_loan_duration, cp.fine_level AS cp_fine_level, cp.age_protect AS cp_age_protect, + cp.circulate AS cp_circulate, cp.deposit AS cp_deposit, cp.ref AS cp_ref, cp.holdable AS cp_holdable, + cp.deposit_amount AS cp_deposit_amount, cp.price AS cp_price, cp.barcode AS cp_barcode, + cp.circ_modifier AS cp_circ_modifier, cp.circ_as_type AS cp_circ_as_type, cp.dummy_title AS cp_dummy_title, + cp.dummy_author AS cp_dummy_author, cp.alert_message AS cp_alert_message, cp.opac_visible AS cp_opac_visible, + cp.deleted AS cp_deleted, cp.floating AS cp_floating, cp.dummy_isbn AS cp_dummy_isbn, + cp.status_changed_time AS cp_status_change_time, cp.active_date AS cp_active_date, + cp.mint_condition AS cp_mint_condition, cp.cost AS cp_cost, + + cs.id AS cs_id, cs.name AS cs_name, cs.holdable AS cs_holdable, cs.opac_visible AS cs_opac_visible, + cs.copy_active AS cs_copy_active, cs.restrict_copy_delete AS cs_restrict_copy_delete, + cs.is_available AS cs_is_available, + + siss.label AS issuance_label, + + cn.id AS cn_id, cn.creator AS cn_creator, cn.create_date AS cn_create_date, cn.editor AS cn_editor, + cn.edit_date AS cn_edit_date, cn.record AS cn_record, cn.owning_lib AS cn_owning_lib, cn.label AS cn_label, + cn.deleted AS cn_deleted, cn.prefix AS cn_prefix, cn.suffix AS cn_suffix, cn.label_class AS cn_label_class, + cn.label_sortkey AS cn_label_sortkey, + + p.id AS p_id, p.record AS p_record, p.label AS p_label, p.label_sortkey AS p_label_sortkey, p.deleted AS p_deleted, + + acnp.label AS ancp_label, acns.label AS ancs_label, + TRIM(acnp.label || ' ' || cn.label || ' ' || acns.label) AS cn_full_label, + + r.bib_record AS record_id, + + t.value AS title, + a.value AS author, + + acpl.id AS acpl_id, acpl.name AS acpl_name, acpl.owning_lib AS acpl_owning_lib, acpl.holdable AS acpl_holdable, + acpl.hold_verify AS acpl_hold_verify, acpl.opac_visible AS acpl_opac_visible, acpl.circulate AS acpl_circulate, + acpl.label_prefix AS acpl_label_prefix, acpl.label_suffix AS acpl_label_suffix, + acpl.checkin_alert AS acpl_checkin_alert, acpl.deleted AS acpl_deleted, acpl.url AS acpl_url, + + COALESCE(acplo.position, acpl_ordered.fallback_position) AS copy_location_order_position, + + ROW_NUMBER() OVER ( + PARTITION BY r.bib_record + ORDER BY h.cut_in_line DESC NULLS LAST, h.request_time DESC + ) AS relative_queue_position, + + EXTRACT(EPOCH FROM COALESCE( + NULLIF(BTRIM(default_estimated_wait_interval.value,'"'),''), + '0 seconds' + )::INTERVAL) AS default_estimated_wait, + + EXTRACT(EPOCH FROM COALESCE( + NULLIF(BTRIM(min_estimated_wait_interval.value,'"'),''), + '0 seconds' + )::INTERVAL) AS min_estimated_wait, + + COALESCE(hold_wait.potenials,0) AS potentials, + COALESCE(hold_wait.other_holds,0) AS other_holds, + COALESCE(hold_wait.total_wait_time,0) AS total_wait_time, + + n.count AS notification_count, + n.max AS last_notification_time + + FROM action.hold_request h + JOIN reporter.hold_request_record r ON (r.id = h.id) + JOIN actor.usr u ON (u.id = h.usr) + JOIN actor.card uc ON (uc.id = u.card) + JOIN actor.usr ru ON (ru.id = h.requestor) + JOIN actor.card ruc ON (ruc.id = ru.card) + JOIN actor.org_unit pl ON (h.pickup_lib = pl.id) + JOIN t_field ON TRUE + JOIN a_field ON TRUE + LEFT JOIN action.hold_request_cancel_cause cc ON (h.cancel_cause = cc.id) + LEFT JOIN biblio.monograph_part p ON (h.hold_type = 'P' AND p.id = h.target) + LEFT JOIN serial.issuance siss ON (h.hold_type = 'I' AND siss.id = h.target) + LEFT JOIN asset.copy cp ON (h.current_copy = cp.id OR (h.hold_type IN ('C','F','R') AND cp.id = h.target)) + LEFT JOIN config.copy_status cs ON (cp.status = cs.id) + LEFT JOIN asset.copy_location acpl ON (cp.location = acpl.id) + LEFT JOIN asset.copy_location_order acplo ON (cp.location = acplo.location AND cp.circ_lib = acplo.org) + LEFT JOIN ( + SELECT *, (ROW_NUMBER() OVER (ORDER BY name) + 1000000) AS fallback_position + FROM asset.copy_location + ) acpl_ordered ON (acpl_ordered.id = cp.location) + LEFT JOIN asset.call_number cn ON (cn.id = cp.call_number OR (h.hold_type = 'V' AND cn.id = h.target)) + LEFT JOIN asset.call_number_prefix acnp ON (cn.prefix = acnp.id) + LEFT JOIN asset.call_number_suffix acns ON (cn.suffix = acns.id) + LEFT JOIN LATERAL (SELECT * FROM action.hold_transit_copy WHERE h.id = hold ORDER BY id DESC LIMIT 1) tr ON TRUE + LEFT JOIN LATERAL (SELECT COUNT(*) FROM action.hold_request_note WHERE h.id = hold AND (pub = TRUE OR staff = $is_staff_request)) notes ON TRUE + LEFT JOIN LATERAL (SELECT COUNT(*), MAX(notify_time) FROM action.hold_notification WHERE h.id = hold) n ON TRUE + LEFT JOIN LATERAL (SELECT FIRST(value) AS value FROM metabib.display_entry WHERE source = r.bib_record AND field = t_field.field) t ON TRUE + LEFT JOIN LATERAL (SELECT FIRST(value) AS value FROM metabib.display_entry WHERE source = r.bib_record AND field = a_field.field) a ON TRUE + LEFT JOIN LATERAL actor.org_unit_ancestor_setting('circ.holds.default_estimated_wait_interval',u.home_ou) AS default_estimated_wait_interval ON TRUE + LEFT JOIN LATERAL actor.org_unit_ancestor_setting('circ.holds.min_estimated_wait_interval',u.home_ou) AS min_estimated_wait_interval ON TRUE + LEFT JOIN LATERAL actor.org_unit_ancestor_setting('circ.hold_shelf_status_delay',h.pickup_lib) AS hold_wait_time ON TRUE, + LATERAL ( + SELECT COUNT(*) AS potenials, + COUNT(DISTINCT hold) AS other_holds, + SUM( + EXTRACT(EPOCH FROM + COALESCE( + cm.avg_wait_time, + COALESCE(NULLIF(BTRIM(default_estimated_wait_interval.value,'"'),''),'0 seconds')::INTERVAL + ) + ) + ) AS total_wait_time + FROM action.hold_copy_map m + JOIN asset.copy cp ON (cp.id = m.target_copy) + LEFT JOIN config.circ_modifier cm ON (cp.circ_modifier = cm.code) + WHERE m.hold = h.id + ) AS hold_wait + WHERE TRUE + SQL + + my %field_map = ( + record_id => 'r.bib_record', + usr_id => 'u.id', + cs_id => 'cs.id', + cp_id => 'cp.id', + cancel_time => 'h.cancel_time', + tr_cancel_time => 'tr.cancel_time', + ); + + my $restricted = 0; + for my $r (keys %$restrictions) { + my $real = $field_map{$r} || $r; + next if ($r =~ /[^a-z_.]/); # skip obvious bad inputs + + my $not = ''; + if (ref($$restrictions{$r}) and ref($$restrictions{$r}) =~ /HASH/) { + $not = 'NOT'; + $$restrictions{$r} = $$restrictions{$r}{not}; + } + + if (!defined($$restrictions{$r})) { + $select .= " AND $real IS $not NULL "; + } elsif (ref($$restrictions{$r})) { + $select .= " AND $real $not IN (\$_$$\$" . join("\$_$$\$,\$_$$\$", @{$$restrictions{$r}}) . "\$_$$\$)"; + } else { + $not = '!' if $not; + $select .= " AND $real $not= \$_$$\$$$restrictions{$r}\$_$$\$"; + } + + $restricted++; + } + + return 0 unless $restricted; + + my @ob; + for my $o (@$order_by) { + next unless $o; + my ($r) = keys %$o; + next if ($r =~ /[^a-z_.]/); # skip obvious bad inputs + my $real = $field_map{$r} || $r; + push(@ob, $real); + $ob[-1] .= ' DESC' if ($$o{$r}->{dir} and $$o{$r}->{dir} =~ /^d/i); + $ob[-1] .= ' NULLS LAST' if ($$o{$r}->{nulls} and $$o{$r}->{nulls} =~ /^l/i); + $ob[-1] .= ' NULLS FIRST' if ($$o{$r}->{nulls} and $$o{$r}->{nulls} =~ /^f/i); + } + + $select .= ' ORDER BY ' . join(', ', @ob) if (@ob); + $select .= ' LIMIT ' . $limit if ($limit and $limit =~ /^\d+$/); + $select .= ' OFFSET ' . $offset if ($offset and $offset =~ /^\d+$/); + + my $sth = action::hold_request->db_Main->prepare($select); + $sth->execute(); + + my @list = $sth->fetchall_hash; + $client->respond(scalar(@list)); # send the row count first, for progress tracking + $client->respond( $_ ) for (@list); + + $client->respond_complete; +} +__PACKAGE__->register_method( + api_name => 'open-ils.storage.action.live_holds.wide_hash', + api_level => 1, + stream => 1, + max_bundle_count=> 1, + method => 'wide_hold_data', +); + + 1; + diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 index b73c8582b3..17fa5f03cf 100644 --- a/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 +++ b/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 @@ -12,10 +12,10 @@ --> - - - - - - - - - - - - - - - - - {{item.hold.current_copy().barcode()}} + path='hold.cp_barcode'> + + {{item.hold.cp_barcode}} - {{item.patron_barcode}} - {{item.patron_alias}} + + {{item.usr_alias}} - + - - - {{item.mvr.title()}} - - + + + {{item.hold.title}} + + - - - + + + - - - + - - - - - - + + + + + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 index 649e346ca0..533b12094c 100644 --- a/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 +++ b/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 @@ -1,13 +1,9 @@ -
- [% l('Loading... [_1]', '{{print_list_progress}}') %] -
- + label="[% l('Print Full List') %]"> - - - - - - - - - - - - - - - - - - {{item.hold.current_copy().barcode()}} + path='hold.cp_barcode'> + + {{item.hold.cp_barcode}} @@ -74,29 +70,130 @@ - + - - - {{item.mvr.title()}} + + + {{item.hold.title}} - - + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js index b2435e9a86..d778ec2cf1 100644 --- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js +++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js @@ -589,7 +589,6 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e conjoinedSvc.fetch($scope.record_id).then(function(){ $scope.conjoinedGridDataProvider.refresh(); }); - egHolds.fetch_holds(hold_ids).then($scope.hold_grid_data_provider.refresh); init_parts_url(); $location.update_path('/cat/catalog/record/' + $scope.record_id); // update_path() bypasses the controller for path @@ -1706,69 +1705,84 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e var provider = egGridDataProvider.instance({}); $scope.hold_grid_data_provider = provider; $scope.grid_actions = egHoldGridActions; - $scope.grid_actions.refresh = function () { provider.refresh() }; + $scope.grid_actions.refresh = function () { holds = []; hold_count = 0; provider.refresh() }; $scope.hold_grid_controls = {}; - var hold_ids = []; // current list of holds - function fetchHolds(offset, count) { - var ids = hold_ids.slice(offset, offset + count); - - return egHolds.fetch_holds(ids).then(null, null, - function(hold_data) { - return hold_data; - } - ); - } - + var holds = []; // current list of holds + var hold_count = 0; provider.get = function(offset, count) { if ($scope.record_tab != 'holds') return $q.when(); - var deferred = $q.defer(); - hold_ids = []; // no caching ATM - // open a determinate progress dialog, max value set below. - egProgressDialog.open({max : 1, value : 0}); + // see if we have the requested range cached + if (holds[offset]) { + return provider.arrayNotifier(holds, offset, count); + } - // fetch the IDs - egCore.net.request( - 'open-ils.circ', - 'open-ils.circ.holds.retrieve_all_from_title', - egCore.auth.token(), $scope.record_id, - {pickup_lib : egCore.org.descendants($scope.pickup_ou.id(), true)} - ).then( - function(hold_data) { - hold_ids = []; // clear the list of ids, hack to avoid dups - // TODO: fix the underlying problem, which is that - // this gets called twice when switching to the holds - // tab; once explicitly, and once via the change handler - // on the OU selector - angular.forEach(hold_data, function(list, type) { - hold_ids = hold_ids.concat(list); - }); + hold_count = 0; + holds = []; + var restrictions = { + is_staff_request : 'true', + fulfillment_time : null, + cancel_time : null, + record_id : $scope.record_id, + pickup_lib : egCore.org.descendants($scope.pickup_ou.id(), true) + }; - // Set the max value of the progress bar to the lesser of - // the total number of holds to fetch or the page size - // of the grid. - egProgressDialog.update( - {max : Math.min(hold_ids.length, count)}); - - var holds_fetched = 0; - fetchHolds(offset, count) - .then(deferred.resolve, null, - function(hold_data) { - holds_fetched++; - deferred.notify(hold_data); - egProgressDialog.increment(); + var order_by = [{ capture_time : null }]; + if (provider.sort && provider.sort.length) { + order_by = []; + angular.forEach(provider.sort, function (c) { + if (!angular.isObject(c)) { + if (c.match(/^hold\./)) { + var i = c.replace('hold.',''); + var ob = {}; + ob[i] = null; + order_by.push(ob); } - )['finally'](egProgressDialog.close); + } else { + var i = Object.keys(c)[0]; + var direction = c[i]; + if (i.match(/^hold\./)) { + i = i.replace('hold.',''); + var ob = {} + ob[i] = {dir:direction}; + order_by.push(ob); + } + } + }); + } + + egProgressDialog.open({max : 1, value : 0}); + var first = true; + return egHolds.fetch_wide_holds( + restrictions, + order_by + ).then(function () { + return provider.arrayNotifier(holds, offset, count); + }, + null, + function(hold_data) { + if (first) { + hold_count = hold_data; + first = false; + egProgressDialog.update({max:hold_count}); + } else { + egProgressDialog.increment(); + var new_item = { id : hold_data.id, hold : hold_data }; + new_item.status_string = + egCore.strings['HOLD_STATUS_' + hold_data.hold_status] + || hold_data.hold_status; + + holds.push(new_item); + } } - ); + ).finally(egProgressDialog.close); - return deferred.promise; } $scope.detail_view = function(action, user_data, items) { if (h = items[0]) { - $scope.detail_hold_id = h.hold.id(); + $scope.detail_hold_id = h.hold.id; } } @@ -1780,28 +1794,43 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou()); $scope.pickup_ou_changed = function(org) { $scope.pickup_ou = org; + holds = [] + hold_count = 0; provider.refresh(); } + function map_prefix_to_subhash (h,pf) { + var newhash = {}; + angular.forEach(Object.keys(h), function (k) { + if (k.startsWith(pf)) { + var nk = k.substr(pf.length); + newhash[nk] = h[k]; + } + }); + return newhash; + } + $scope.print_holds = function() { - var holds = []; - angular.forEach($scope.hold_grid_controls.allItems(), function(item) { - holds.push({ - hold : egCore.idl.toHash(item.hold), - patron_last : item.patron_last, - patron_alias : item.patron_alias, - patron_barcode : item.patron_barcode, - copy : egCore.idl.toHash(item.copy), - volume : egCore.idl.toHash(item.volume), - title : item.mvr.title(), - author : item.mvr.author() + var pholds = []; + angular.forEach(holds, function(item) { + pholds.push({ + hold : item.hold, + status_string : item.status_string, + patron_first : item.hold.usr_first_given_name, + patron_last : item.hold.usr_family_name, + patron_alias : item.hold.usr_alias, + patron_barcode : item.hold.ucard_barcode, + copy : map_prefix_to_subhash(item.hold,'cp_'), + volume : map_prefix_to_subhash(item.hold,'cn_'), + title : item.hold.title, + author : item.hold.author }); }); egCore.print.print({ context : 'receipt', template : 'holds_for_bib', - scope : {holds : holds} + scope : {holds : pholds} }); } @@ -1817,7 +1846,7 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e // UI presents this option as "all holds" $scope.transfer_holds_to_marked = function() { var hold_ids = $scope.hold_grid_controls.allItems().map( - function(hold_data) {return hold_data.hold.id()}); + function(hold_data) {return hold_data.hold.id}); egHolds.transfer_to_marked_title(hold_ids); } diff --git a/Open-ILS/web/js/ui/default/staff/circ/holds/app.js b/Open-ILS/web/js/ui/default/staff/circ/holds/app.js index fc9ea34c51..6456aad800 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/holds/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/holds/app.js @@ -37,8 +37,8 @@ angular.module('egHoldsApp', .controller('HoldsShelfCtrl', - ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egHoldGridActions','egCirc','egGridDataProvider', -function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egHoldGridActions , egCirc , egGridDataProvider) { + ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egHoldGridActions','egCirc','egGridDataProvider','egProgressDialog', +function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egHoldGridActions , egCirc , egGridDataProvider , egProgressDialog) { $scope.detail_hold_id = $routeParams.hold_id; var hold_ids = []; @@ -47,20 +47,11 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e $scope.gridControls = {}; $scope.grid_actions = egHoldGridActions; - function fetch_holds(offset, count) { - var ids = hold_ids.slice(offset, offset + count); - return egHolds.fetch_holds(ids).then(null, null, - function(hold_data) { - holds.push(hold_data); - return hold_data; // to the grid - } - ); - } - var provider = egGridDataProvider.instance({}); $scope.gridDataProvider = provider; function refresh_page() { + hold_count = 0; holds = []; hold_ids = []; provider.refresh(); @@ -75,35 +66,74 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e return provider.arrayNotifier(holds, offset, count); } - // see if we have the holds IDs for this range already loaded - if (hold_ids[offset]) { - return fetch_holds(offset, count); - } + // if in clear mode... + if (clear_mode && holds.length) { + holds = holds.filter(function(h) { return h.hold.clear_me == 't' }); + } - var deferred = $q.defer(); - hold_ids = []; + hold_count = 0; holds = []; + var restrictions = { + is_staff_request : 'true', + capture_time : { not : null }, + cs_id : 8, // on holds shelf + fulfillment_time : null, + current_shelf_lib : $scope.pickup_ou.id() + }; + + var order_by = [{ capture_time : null }]; + if (provider.sort && provider.sort.length) { + order_by = []; + angular.forEach(provider.sort, function (c) { + if (!angular.isObject(c)) { + if (c.match(/^hold\./)) { + var i = c.replace('hold.',''); + var ob = {}; + ob[i] = null; + order_by.push(ob); + } + } else { + var i = Object.keys(c)[0]; + var direction = c[i]; + if (i.match(/^hold\./)) { + i = i.replace('hold.',''); + var ob = {} + ob[i] = {dir:direction}; + order_by.push(ob); + } + } + }); + } - var method = 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve.authoritative.atomic'; - if (clear_mode) - method = 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve.atomic'; - - egCore.net.request( - 'open-ils.circ', method, - egCore.auth.token(), $scope.pickup_ou.id() - - ).then(function(ids) { - if (!ids.length) { - deferred.resolve(); - return; + egProgressDialog.open({max : 1, value : 0}); + var first = true; + return egHolds.fetch_wide_holds( + restrictions, + order_by + ).then(function () { + return provider.arrayNotifier(holds, offset, count); + }, + null, + function(hold_data) { + if (first) { + hold_count = hold_data; + first = false; + egProgressDialog.update({max:hold_count}); + } else { + egProgressDialog.increment(); + var new_item = { id : hold_data.id, hold : hold_data }; + new_item.status_string = + egCore.strings['HOLD_STATUS_' + hold_data.hold_status] + || hold_data.hold_status; + + if (clear_mode) { + if (hold_data.clear_me == 't') holds.push(new_item); + } else { + holds.push(new_item); + } + } } - - hold_ids = ids; - fetch_holds(offset, count) - .then(deferred.resolve, null, deferred.notify); - }); - - return deferred.promise; + ).finally(egProgressDialog.close); } // re-draw the grid when user changes the org selector @@ -115,7 +145,7 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e $scope.detail_view = function(action, user_data, items) { if (h = items[0]) { - $location.path('/circ/holds/shelf/' + h.hold.id()); + $location.path('/circ/holds/shelf/' + h.hold.id); } } @@ -126,7 +156,7 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e // when the detail hold is fetched (and updated), update the bib // record summary display record id. $scope.set_hold = function(hold_data) { - $scope.detail_hold_record_id = hold_data.mvr.doc_id(); + $scope.detail_hold_record_id = hold_data.hold.record_id; } // manage active vs. clearable holds display @@ -144,7 +174,7 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e angular.forEach(resp, function(info) { if (info.action) { var grid_item = holds.filter(function(item) { - return item.hold.id() == info.hold_details.id + return item.hold.id == info.hold_details.id })[0]; // there will be no grid item if the hold is off-page @@ -197,48 +227,45 @@ function($scope , $q , $routeParams , $window , $location , egCore , egHolds , e ); } - $scope.print_list_progress = null; + function map_prefix_to_subhash (h,pf) { + var newhash = {}; + angular.forEach(Object.keys(h), function (k) { + if (k.startsWith(pf)) { + var nk = k.substr(pf.length); + newhash[nk] = h[k]; + } + }); + return newhash; + } + $scope.print_shelf_list = function() { var print_holds = []; - $scope.print_list_loading = true; - $scope.print_list_progress = 0; + angular.forEach(holds, function(hold_data) { + var phold = {}; + print_holds.push(phold); - // collect the full list of holds - var method = 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve.authoritative.atomic'; - if (clear_mode) - method = 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve.atomic'; - egCore.net.request( - 'open-ils.circ', - method, - egCore.auth.token(), $scope.pickup_ou.id() - ).then( function(idlist) { - - egHolds.fetch_holds(idlist).then( - function () { - console.debug('printing ' + print_holds.length + ' holds'); - // holds fetched, send to print - egCore.print.print({ - context : 'default', - template : 'hold_shelf_list', - scope : {holds : print_holds} - }) - }, - null, - function(hold_data) { - $scope.print_list_progress++; - egHolds.local_flesh(hold_data); - print_holds.push(hold_data); - hold_data.title = hold_data.mvr.title(); - hold_data.author = hold_data.mvr.author(); - hold_data.hold = egCore.idl.toHash(hold_data.hold); - hold_data.copy = egCore.idl.toHash(hold_data.copy); - hold_data.volume = egCore.idl.toHash(hold_data.volume); - hold_data.part = egCore.idl.toHash(hold_data.part); - } - ) - }).finally(function() { - $scope.print_list_loading = false; - $scope.print_list_progress = null; + phold.status_string = hold_data.status_string; + + phold.patron_first = hold_data.hold.usr_first_given_name; + phold.patron_last = hold_data.hold.usr_family_name; + phold.patron_alias = hold_data.hold.usr_alias; + phold.patron_barcode = hold_data.hold.ucard_barcode; + + phold.title = hold_data.hold.title; + phold.author = hold_data.hold.author; + + phold.hold = hold_data.hold; + phold.copy = map_prefix_to_subhash(hold_data.hold, 'cp_'); + phold.volume = map_prefix_to_subhash(hold_data.hold, 'cn_'); + phold.part = map_prefix_to_subhash(hold_data.hold, 'p_'); + }); + + console.log(print_holds); + + return egCore.print.print({ + context : 'default', + template : 'hold_shelf_list', + scope : {holds : print_holds} }); } diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/holds.js b/Open-ILS/web/js/ui/default/staff/circ/services/holds.js index 656e7c4a41..f76d92b014 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/services/holds.js +++ b/Open-ILS/web/js/ui/default/staff/circ/services/holds.js @@ -11,6 +11,15 @@ function($uibModal , $q , egCore , egConfirmDialog , egAlertDialog) { var service = {}; + service.fetch_wide_holds = function(restrictions, order_by, limit, offset) { + return egCore.net.request( + 'open-ils.circ', + 'open-ils.circ.hold.wide_hash.stream', + egCore.auth.token(), + restrictions, order_by, limit, offset + ); + } + service.fetch_holds = function(hold_ids) { var deferred = $q.defer(); @@ -531,6 +540,14 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { return egHolds.cancel_holds(hold_ids).then(service.refresh); } + service.cancel_wide_hold = function(items) { + var hold_ids = items.filter(function(item) { + return !item.hold.cancel_time; + }).map(function(item) {return item.hold.id}); + + return egHolds.cancel_holds(hold_ids).then(service.refresh); + } + service.uncancel_hold = function(items) { var hold_ids = items.filter(function(item) { return item.hold.cancel_time(); @@ -539,6 +556,14 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { return egHolds.uncancel_holds(hold_ids).then(service.refresh); } + service.uncancel_wide_hold = function(items) { + var hold_ids = items.filter(function(item) { + return item.hold.cancel_time; + }).map(function(item) {return item.hold.id}); + + return egHolds.uncancel_holds(hold_ids).then(service.refresh); + } + // jump to circ list for either 1) the targeted copy or // 2) the hold target copy for copy-level holds service.show_recent_circs = function(items) { @@ -554,6 +579,21 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { }); } + // jump to circ list for either 1) the targeted copy or + // 2) the hold target copy for copy-level holds + service.show_recent_circs_wide = function(items) { + var focus = items.length == 1; + angular.forEach(items, function(item) { + if (item.hold.cp_id) { + var url = egCore.env.basePath + + '/cat/item/' + + item.hold.cp_id + + '/circ_list'; + $timeout(function() { var x = $window.open(url, '_blank'); if (focus) x.focus() }); + } + }); + } + service.show_patrons = function(items) { var focus = items.length == 1; angular.forEach(items, function(item) { @@ -565,6 +605,17 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { }); } + service.show_patrons_wide = function(items) { + var focus = items.length == 1; + angular.forEach(items, function(item) { + var url = egCore.env.basePath + + 'circ/patron/' + + item.hold.usr_id + + '/holds'; + $timeout(function() { var x = $window.open(url, '_blank'); if (focus) x.focus() }); + }); + } + service.show_holds_for_title = function(items) { var focus = items.length == 1; angular.forEach(items, function(item) { @@ -576,6 +627,17 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { }); } + service.show_holds_for_title_wide = function(items) { + var focus = items.length == 1; + angular.forEach(items, function(item) { + var url = egCore.env.basePath + + 'cat/catalog/record/' + + item.hold.record_id + + '/holds'; + $timeout(function() { var x = $window.open(url, '_blank'); if (focus) x.focus() }); + }); + } + function generic_update(items, action) { if (!items.length) return $q.when(); @@ -583,6 +645,12 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { return egHolds[action](hold_ids).then(service.refresh); } + function generic_update_wide(items, action) { + if (!items.length) return $q.when(); + var hold_ids = items.map(function(item) {return item.hold.id}); + return egHolds[action](hold_ids).then(service.refresh); + } + service.set_copy_quality = function(items) { generic_update(items, 'set_copy_quality'); } service.edit_pickup_lib = function(items) { @@ -602,6 +670,25 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { service.transfer_to_marked_title = function(items) { generic_update(items, 'transfer_to_marked_title'); } + service.set_copy_quality_wide = function(items) { + generic_update_wide(items, 'set_copy_quality'); } + service.edit_pickup_lib_wide = function(items) { + generic_update_wide(items, 'edit_pickup_lib'); } + service.edit_notify_prefs_wide = function(items) { + generic_update_wide(items, 'edit_notify_prefs'); } + service.edit_dates_wide = function(items) { + generic_update_wide(items, 'edit_dates'); } + service.suspend_wide = function(items) { + generic_update_wide(items, 'suspend_holds'); } + service.activate_wide = function(items) { + generic_update_wide(items, 'activate_holds'); } + service.set_top_of_queue_wide = function(items) { + generic_update_wide(items, 'set_top_of_queue'); } + service.clear_top_of_queue_wide = function(items) { + generic_update_wide(items, 'clear_top_of_queue'); } + service.transfer_to_marked_title_wide = function(items) { + generic_update_wide(items, 'transfer_to_marked_title'); } + service.mark_damaged = function(items) { angular.forEach(items, function(item) { if (item.copy) { @@ -613,6 +700,17 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { }); } + service.mark_damaged_wide = function(items) { + angular.forEach(items, function(item) { + if (item.copy) { + egCirc.mark_damaged({ + id: item.hold.cp_id, + barcode: item.hold.cp_barcode + }).then(service.refresh); + } + }); + } + service.mark_missing = function(items) { var copy_ids = items .filter(function(item) { return Boolean(item.copy) }) @@ -621,11 +719,24 @@ function($window , $location , $timeout , egCore , egHolds , egCirc) { egCirc.mark_missing(copy_ids).then(service.refresh); } + service.mark_missing_wide = function(items) { + var copy_ids = items + .filter(function(item) { return Boolean(item.hold.cp_id) }) + .map(function(item) { return item.hold.cp_id }); + if (copy_ids.length) + egCirc.mark_missing(copy_ids).then(service.refresh); + } + service.retarget = function(items) { var hold_ids = items.map(function(item) { return item.hold.id() }); egHolds.retarget(hold_ids).then(service.refresh); } + service.retarget_wide = function(items) { + var hold_ids = items.map(function(item) { return item.hold.id }); + egHolds.retarget(hold_ids).then(service.refresh); + } + return service; }]) diff --git a/Open-ILS/web/js/ui/default/staff/services/hatch.js b/Open-ILS/web/js/ui/default/staff/services/hatch.js index 12b144d846..bb124795d7 100644 --- a/Open-ILS/web/js/ui/default/staff/services/hatch.js +++ b/Open-ILS/web/js/ui/default/staff/services/hatch.js @@ -595,7 +595,11 @@ angular.module('egCoreMod') } else if (value === undefined) { return; } - $window.localStorage.setItem(key, jsonified); + try { + $window.localStorage.setItem(key, jsonified); + } catch (e) { + console.log('localStorage.setItem (overwrite) failed for '+key+': ', e); + } } service.appendItem = function(key, value) { @@ -631,7 +635,11 @@ angular.module('egCoreMod') jsonified = JSON.stringify(value); var old_value = $window.localStorage.getItem(key) || ''; - $window.localStorage.setItem( key, old_value + jsonified ); + try { + $window.localStorage.setItem( key, old_value + jsonified ); + } catch (e) { + console.log('localStorage.setItem (append) failed for '+key+': ', e); + } } // Set the value for the given key. -- 2.43.2