LP#1838995: Hold group buckets
authorMike Rylander <mrylander@gmail.com>
Fri, 6 Sep 2019 18:49:52 +0000 (14:49 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Mon, 8 Mar 2021 15:50:27 +0000 (10:50 -0500)
This feature allows staff to add multiple users to a named hold group
bucket and place title-level holds for a record for that entire set of users.
Users can be added to such a hold group bucket from either the patron
search result interface, via the Add to Bucket dropdown, or through a dedicated
Hold Groups interface available from the Circulation menu.  Adding new
patrons to a hold group bucket will require staff have the PLACE_HOLD
permission.

Holds can be placed for the users in a hold group bucket either directly from
the normal staff-place hold interface in the embedded OPAC, or by supplying the
record ID within the hold group 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
hold group bucket hold will requires staff have the MANAGE_HOLD_GROUPS
permission, which is new with this development.

In the event of a mistaken hold group hold, staff with the
MANAGE_HOLD_GROUPS permission will have the ability to cancel all unfulfilled
holds created as part of a hold group hold event.

A link to the title's hold interface is available from the list of hold group
hold events in the dedicated hold group hold interface.

Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Dawn Dale <ddale@georgialibraries.org>
Signed-off-by: Chauncey Montgomery <chauncey@yourcl.org>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
38 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm
Open-ILS/src/sql/Pg/090.schema.action.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.hold_buckets.sql [new file with mode: 0644]
Open-ILS/src/templates-bootstrap/opac/myopac/hold_subscriptions.tt2 [new file with mode: 0644]
Open-ILS/src/templates-bootstrap/opac/parts/myopac/base.tt2
Open-ILS/src/templates-bootstrap/opac/parts/place_hold.tt2
Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
Open-ILS/src/templates/opac/myopac/hold_history.tt2
Open-ILS/src/templates/opac/myopac/hold_subscriptions.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/holds.tt2
Open-ILS/src/templates/opac/parts/place_hold.tt2
Open-ILS/src/templates/staff/cat/bucket/batch_hold/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_bucket_info.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_event_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_grid_menu.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/batch_hold/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_create.tt2
Open-ILS/src/templates/staff/cat/bucket/share/t_bucket_edit.tt2
Open-ILS/src/templates/staff/circ/patron/index.tt2
Open-ILS/src/templates/staff/circ/patron/t_hold_subscriptions.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/opac/staff.js
Open-ILS/web/js/ui/default/staff/cat/bucket/batch_hold/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/bucket/app.js
Open-ILS/web/js/ui/default/staff/services/user-bucket.js
docs/RELEASE_NOTES_NEXT/Circulation/hold-subscriptions.adoc [new file with mode: 0644]

index 7913925..42cad63 100644 (file)
@@ -7557,6 +7557,47 @@ SELECT  usr,
             </actions>
         </permacrud>
        </class>
+       <class id="abhe" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::batch_hold_event" oils_persist:tablename="action.batch_hold_event" reporter:label="Hold Group Event">
+               <fields oils_persist:primary="id" oils_persist:sequence="action.batch_hold_event_id_seq">
+                       <field name="id" reporter:datatype="id" />
+                       <field name="staff" reporter:datatype="link"/>
+                       <field name="bucket" reporter:datatype="link"/>
+                       <field name="target" reporter:datatype="int" />
+                       <field name="hold_type" reporter:datatype="text" />
+                       <field name="run_date" reporter:datatype="timestamp" />
+                       <field name="cancelled" reporter:datatype="timestamp" />
+                       <field name="mappings" oils_persist:virtual="true" reporter:datatype="link" />
+               </fields>
+               <links>
+                       <link field="staff" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="bucket" reltype="has_a" key="id" map="" class="cub"/>
+                       <link field="mappings" reltype="has_many" key="batch_hold_event" map="" class="abhem"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                <create permission="MANAGE_HOLD_GROUPS" global_required="true"/>
+                <retrieve/>
+                <update permission="MANAGE_HOLD_GROUPS" global_required="true"/>
+                <delete permission="MANAGE_HOLD_GROUPS" global_required="true"/>
+            </actions>
+        </permacrud>
+       </class>
+       <class id="abhem" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::batch_hold_event_map" oils_persist:tablename="action.batch_hold_event_map" reporter:label="Hold Group Event Map">
+               <fields oils_persist:primary="id" oils_persist:sequence="action.batch_hold_event_map_id_seq">
+                       <field name="id" reporter:datatype="id" />
+                       <field name="batch_hold_event" reporter:datatype="link"/>
+                       <field name="hold" reporter:datatype="link"/>
+               </fields>
+               <links>
+                       <link field="batch_hold_event" reltype="has_a" key="id" map="" class="abhe"/>
+                       <link field="hold" reltype="has_a" key="id" map="" class="ahr"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                <retrieve/>
+            </actions>
+        </permacrud>
+       </class>
        <class id="cubin" controller="open-ils.cstore" oils_obj:fieldmapper="container::user_bucket_item_note" oils_persist:tablename="container.user_bucket_item_note" reporter:label="User Bucket Item Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.user_bucket_item_note_id_seq">
                        <field name="id" reporter:datatype="id" />
index e468a0b..46b9888 100644 (file)
@@ -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);
         }
index 25f2633..d0b5136 100644 (file)
@@ -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_HOLD_GROUPS should be universal
+    my $evt = $U->check_perms($e->requestor->id, $org, 'MANAGE_HOLD_GROUPS');
+    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 => 'Hold Group 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_HOLD_GROUPS');
+    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) = @_;
 
index 111ea8b..ef231fe 100644 (file)
@@ -253,6 +253,7 @@ sub load {
     $self->load_current_curbside_libs;
 
     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|;
@@ -409,6 +410,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;
index 4da79eb..f77c1e0 100644 (file)
@@ -1430,6 +1430,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 {
@@ -1443,6 +1467,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');
@@ -1671,8 +1696,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;
 
@@ -1685,17 +1710,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 Group Event requested for user bucket: " . $ctx->{hold_subscription});
+            $usr = $e->retrieve_container_user_bucket($ctx->{hold_subscription});
         }
     }
 
@@ -1761,22 +1795,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);
+    # 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;
+            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';
@@ -1801,17 +1845,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) {
 
@@ -1823,6 +1881,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};
@@ -1836,7 +1901,6 @@ sub attempt_hold_placement {
 
                 if(not ref $result and $result > 0) {
                     # successul hold returns the hold ID
-
                     $hdata->{hold_success} = $result;
 
                 } else {
index 730586a..f741bca 100644 (file)
@@ -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;
index 960e6ee..813ab0b 100644 (file)
@@ -1762,5 +1762,21 @@ CREATE TABLE action.curbside (
     notes       TEXT
 );
 
+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
+);
+
 COMMIT;
 
index 781781b..55072f4 100644 (file)
@@ -1950,7 +1950,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 626, 'VIEW_BOOKING_RESERVATION_ATTR_MAP', oils_i18n_gettext(626,
     'View booking reservation attribute maps', 'ppl', 'description')),
  ( 627, 'SSO_ADMIN', oils_i18n_gettext(627,
-    'Modify patron SSO settings', 'ppl', 'description'))
+    'Modify patron SSO settings', 'ppl', 'description')),
+ ( 628, 'MANAGE_HOLD_GROUPS', oils_i18n_gettext(628,
+    'Manage batch (subscription) hold events', 'ppl', 'description'))
 ;
 
 
@@ -2900,6 +2902,7 @@ INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (4, oils_i18n_get
 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (5, oils_i18n_gettext(5, 'Staff forced', 'ahrcc', 'label'));
 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (6, oils_i18n_gettext(6, 'Patron via OPAC', 'ahrcc', 'label'));
 INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (7, oils_i18n_gettext(7, 'Patron via SIP', 'ahrcc', 'label'));
+INSERT INTO action.hold_request_cancel_cause (id,label) VALUES (8, oils_i18n_gettext(8, 'Hold Group Event rollback', 'ahrcc', 'label'));
 SELECT SETVAL('action.hold_request_cancel_cause_id_seq', 100);
 
 
@@ -5838,6 +5841,7 @@ INSERT INTO container.user_bucket_type (code,label) VALUES ('folks:hold.view', o
 INSERT INTO container.user_bucket_type (code,label) VALUES ('folks:hold.cancel', oils_i18n_gettext('folks:hold.cancel', 'Cancel Holds', 'cubt', 'label'));
 
 INSERT INTO container.user_bucket_type (code,label) SELECT code,label FROM container.copy_bucket_type where code = 'staff_client';
+INSERT INTO container.user_bucket_type (code,label) VALUES ('hold_subscription', oils_i18n_gettext('hold_subscription', 'Hold Group Container', 'cubt', 'label'));
 
 ----------------------------------
 -- MARC21 record structure data --
@@ -17372,6 +17376,78 @@ VALUES (currval('action_trigger.event_definition_id_seq'), 'home_ou'),
        (currval('action_trigger.event_definition_id_seq'), 'home_ou.mailing_address'),
        (currval('action_trigger.event_definition_id_seq'), 'home_ou.billing_address');
 
+INSERT INTO action_trigger.event_definition (active, owner, name, hook, validator, reactor, delay, delay_field, group_field, cleanup_success, template)
+    VALUES ('f', 1, 'Hold Group 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, 'Hold Group 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);
 
 INSERT INTO config.org_unit_setting_type
 (name, grp, label, description, datatype)
@@ -20348,6 +20424,26 @@ VALUES
  'string', 627)
 ;
 
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype)
+VALUES (
+    'holds.subscription.randomize',
+    oils_i18n_gettext(
+        'holds.subscription.randomize',
+        'Randomize group hold order',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'holds.subscription.randomize',
+        'When placing a batch group hold, randomize the order of the patrons receiving the holds so they are not always in the same order.',
+        'coust',
+        'description'
+    ),
+    'holds',
+    'bool'
+);
+
 INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
 VALUES (
     'eg.print.config.default', 'gui', 'object',
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 (file)
index 0000000..1260f6e
--- /dev/null
@@ -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 Group Container');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype)
+VALUES (
+    'holds.subscription.randomize',
+    oils_i18n_gettext(
+        'holds.subscription.randomize',
+        'Randomize group hold order',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'holds.subscription.randomize',
+        'When placing a batch group 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 ( 623, 'MANAGE_HOLD_GROUPS', oils_i18n_gettext(623, 'Manage hold groups and hold group events', 'ppl', 'description'));
+
+INSERT INTO action.hold_request_cancel_cause (id,label)
+  VALUES ( 8, oils_i18n_gettext(8, 'Hold Group 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, 'Hold Group 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, 'Hold Group 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-bootstrap/opac/myopac/hold_subscriptions.tt2 b/Open-ILS/src/templates-bootstrap/opac/myopac/hold_subscriptions.tt2
new file mode 100644 (file)
index 0000000..d5d6c07
--- /dev/null
@@ -0,0 +1,51 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/hold_status.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "holds";
+    parent="holds";
+%]
+<div id='myopac_holds_div'>
+
+    <div class="header_middle">
+        <span style="float:left;">[% l("Current Hold Groups") %]</span>
+        <span style="float:right;">
+            <a class="hide_me" href="#">[% l('Export List') %]</a>
+        </span>
+    </div>
+    <div class="clear-both"></div>
+
+    <div id='holds_main'>
+        [% IF ctx.my_hold_subscriptions.size AND ctx.my_hold_subscriptions.size < 1 %]
+        <div class="warning_box">
+            <big><strong>[% l('No subscriptions found.') %]</strong></big>
+        </div>
+        [% ELSE %]
+        <table id='acct_holds_hist_header' class='table_no_border_space table_no_cell_pad' title="[% l('Hold Groups') %]">
+            <thead>
+                <tr>
+                    <th><span>[% l('Name') %]</span></th>
+                    <th><span>[% l('Description') %]</span></th>
+                    <th><span>[% l('Actions') %]</span></th>
+                </tr>
+            </thead>
+            <tbody id='holds_temp_parent'>
+                [% FOR sub IN ctx.my_hold_subscriptions %]
+                <tr name="acct_holds_temp" class="acct_holds_temp">
+
+                    <td> [% sub.name | html %] </td>
+                    <td> [% sub.description | html %] </td>
+                    <td>
+                        <a href='[% mkurl('hold_subscriptions', {remove => sub.id}) %]'>
+                            [% l('Remove me') %]
+                        </a>
+                    </td>
+                </tr>
+                [% END %]
+            </tbody>
+        </table>
+        [% END %]
+    </div>
+</div>
+[% END %]
index 0374be1..14e425a 100755 (executable)
@@ -32,6 +32,9 @@
      IF (ctx.show_reservations_tab == 'true');
         myopac_pages.push({children => 0, parent => "parent", url => "reservations", text => l("<i class='fas fa-splotch' aria-hidden='true'></i>  Reservations"), name => l("Reservations")});
     END;
+    IF ctx.my_hold_subscriptions.size > 0;
+        myopac_pages.push({children => 0, parent => "holds", url => "hold_subscriptions", text => l("<i class='fas fa-redo' aria-hidden='true'></i> Hold Groups"), name => l("Hold Groups")});
+    END;
     skin_root = "../"
 %]
 
index 652a0a1..b4dc99b 100755 (executable)
@@ -129,6 +129,28 @@ function maybeToggleNumCopies(obj) {
                     [% l("Place this hold for me ([_1] [_2])", ctx.user.first_given_name, ctx.user.family_name) | html %]
                 </label>
             </span>
+            [% IF CGI.param('hold_type') == 'T' AND ctx.hold_subscriptions.size > 0 AND NOT CGI.param('from_basket') %]
+              <br />
+              <!-- request for a reading group / subscription -->
+              <input type="radio" id="hold_usr_is_subscription"
+                  onchange="staff_hold_usr_input_disabler(this);"
+                  name="hold_usr_is_requestor" value="2"
+                  />
+              <label for="hold_usr_is_subscription">
+                  [% l("Place hold for patron Hold Group:") %]
+              </label>
+              <select id='select_hold_subscription' name='hold_subscription'>
+                  <option selected='selected' value=''>[% l('- Hold Groups -') %]</option>
+                  [% FOR sub IN ctx.hold_subscriptions %]
+                  <option value='[% sub.id %]'>[% sub.name | html %]</option>
+                  [% END %]
+              </select>
+            [% END %]
+            <br/>
+            <label>
+              <input id="override_blocks_subscription" name="override" type="checkbox" checked="checked"/>
+              [% l("Override all hold-blocking conditions possible?") %]
+            </label>
         </p>
         [% END %]
         [% END %]
index bbf3b7f..4921785 100644 (file)
         <div class="align">
             <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
         </div>
+        [% IF ctx.my_hold_subscriptions.size > 0 %]
+        <div class="align">
+            <a href='[% mkurl('hold_subscriptions', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Hold Groups") %]</a>
+        </div>
+        [% END %]
         <div class="align">
             <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
         </div>
index 578abeb..97b6ee2 100644 (file)
         <div class="align selected">
             <a href='#'>[% l("E-Items Ready for Checkout") %]</a>
         </div>
+        [% IF ctx.my_hold_subscriptions.size > 0 %]
+        <div class="align">
+            <a href='[% mkurl('hold_subscriptions', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Hold Groups") %]</a>
+        </div>
+        [% END %]
         <div class="align">
             <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
         </div>
index c06ab40..cf8af4c 100644 (file)
             <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
         </div>
         [% END %]
+        [% IF ctx.my_hold_subscriptions.size > 0 %]
+        <div class="align">
+            <a href='[% mkurl('hold_subscriptions', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Hold Groups") %]</a>
+        </div>
+        [% END %]
         <div class="align selected">
             <a href="#">[% l("Holds History") %]</a>
         </div>
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 (file)
index 0000000..51fa081
--- /dev/null
@@ -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;
+%]
+
+<h3 class="sr-only">[% l('Holds History') %]</h3>
+<div id='myopac_holds_div'>
+
+    <div id="acct_holds_tabs">
+        <div class="align">
+            <a href='[% mkurl('holds',{},['limit','offset']) %]'>[% l("Items on Hold") %]</a>
+        </div>
+        [% IF ebook_api.enabled == 'true' %]
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        [% END %]
+        [% IF ctx.my_hold_subscriptions.size > 0 %]
+        <div class="align selected">
+            <a href='#'>[% l("Hold Groups") %]</a>
+        </div>
+        [% END %]
+        <div class="align">
+            <a href="[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]">[% l("Holds History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span style="float:left;">[% l("Current Hold Groups") %]</span>
+        <span style="float:right;">
+            <a class="hide_me" href="#">[% l('Export List') %]</a>
+        </span>
+    </div>
+    <div class="clear-both"></div>
+
+    <div id='holds_main'>
+        [% IF ctx.my_hold_subscriptions.size AND ctx.my_hold_subscriptions.size < 1 %]
+        <div class="warning_box">
+            <big><strong>[% l('No subscriptions found.') %]</strong></big>
+        </div>
+        [% ELSE %]
+        <table id='acct_holds_hist_header' class='table_no_border_space table_no_cell_pad' title="[% l('Hold Groups') %]">
+            <thead>
+                <tr>
+                    <td><span>[% l('Name') %]</span></td>
+                    <td><span>[% l('Description') %]</span></td>
+                    <td><span>[% l('Actions') %]</span></td>
+                </tr>
+            </thead>
+            <tbody id='holds_temp_parent'>
+                [% FOR sub IN ctx.my_hold_subscriptions %]
+                <tr name="acct_holds_temp" class="acct_holds_temp">
+
+                    <td> [% sub.name | html %] </td>
+                    <td> [% sub.description | html %] </td>
+                    <td>
+                        <a href='[% mkurl('hold_subscriptions', {remove => sub.id}) %]'>
+                            [% l('Remove me') %]
+                        </a>
+                    </td>
+                </tr>
+                [% END %]
+            </tbody>
+        </table>
+        [% END %]
+    </div>
+</div>
+[% END %]
index a4242fe..ab1ed4d 100644 (file)
             <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
         </div>
         [% END %]
+        [% IF ctx.my_hold_subscriptions.size > 0 %]
+        <div class="align">
+            <a href='[% mkurl('hold_subscriptions', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Hold Groups") %]</a>
+        </div>
+        [% END %]
         <div class="align">
             <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
         </div>
index 1f0989d..809def3 100644 (file)
@@ -97,6 +97,7 @@ function maybeToggleNumCopies(obj) {
         %]
 
         [% IF ctx.is_staff %]
+        <!-- request for a patron -->
         <p class="staff-hold">
             <input type="radio" id="hold_usr_is_requestor_not"
                 onchange="staff_hold_usr_input_disabler(this);"
@@ -128,8 +129,31 @@ function maybeToggleNumCopies(obj) {
                     [% l("Place this hold for me ([_1] [_2])", ctx.user.first_given_name, ctx.user.family_name) | html %]
                 </label>
             </span>
+            [% IF CGI.param('hold_type') == 'T' AND ctx.hold_subscriptions.size > 0 AND NOT CGI.param('from_basket') %]
+              <br />
+              <!-- request for a reading group / subscription -->
+              <input type="radio" id="hold_usr_is_subscription"
+                  onchange="staff_hold_usr_input_disabler(this);"
+                  name="hold_usr_is_requestor" value="2"
+                  />
+              <label for="hold_usr_is_subscription">
+                  [% l("Place hold for patron Hold Group:") %]
+              </label>
+              <select id='select_hold_subscription' name='hold_subscription'>
+                  <option selected='selected' value=''>[% l('- Hold Groups -') %]</option>
+                  [% FOR sub IN ctx.hold_subscriptions %]
+                  <option value='[% sub.id %]'>[% sub.name | html %]</option>
+                  [% END %]
+              </select>
+            [% END %]
+            <br/>
+            <label>
+              <input id="override_blocks_subscription" name="override" type="checkbox" checked="checked"/>
+              [% l("Override all hold-blocking conditions possible?") %]
+            </label>
         </p>
         [% END %]
+
       [% END %]
 
         <table id='hold-items-list'>
@@ -244,7 +268,7 @@ function maybeToggleNumCopies(obj) {
                     [% IF ctx.default_phone_notify %]checked="checked"[% END %]/>
                     <label for="phone_notify_checkbox">[% l('Yes, by Phone') %]</label><br/>
                 <blockquote>
-                    <label>[% l('Phone Number:') %]<input type="text" name="phone_notify" [% setting = 'opac.default_phone';
+                    <label>[% l('Phone Number:') %]<input type="text" id="phone_notify" name="phone_notify" [% setting = 'opac.default_phone';
                     IF ctx.user_setting_map.$setting; %] value='[% ctx.user_setting_map.$setting | html %]'
                     [%- ELSIF ctx.user.day_phone; %] value='[% ctx.user.day_phone | html %]' [% END %]/></label>
                 </blockquote>
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 (file)
index 0000000..3cee21d
--- /dev/null
@@ -0,0 +1,82 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Hold Group Buckets"); 
+  ctx.page_app = "egCatBatchHoldBuckets";
+  ctx.page_ctrl = "BatchHoldBucketCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/bucket/batch_hold/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
+<script>
+  angular.module('egCoreMod').run(['egStrings', function(s) {
+    s.CONFIRM_DELETE_USER_BUCKET_ITEMS_FROM_CATALOG =
+      "[% l('Are you sure you want to delete selected users in bucket from catalog?') %]";
+    s.EVENT_ROLLBACK_TITLE =
+      "[% l('Cancel all holds created by selected events') %]";
+    s.EVENT_NO_TARGET =
+      "[% l('No target provided for hold group') %]";
+    s.EVENT_INVALID_TARGET =
+      "[% l('Invalid target provided for hold group') %]";
+    s.EVENT_CREATE_SUMMARY =
+      "[% l('Created holds for [_1] of [_2] patrons', '{{success}}', '{{total}}') %]";
+  }])
+</script>
+[% END %]
+
+<!-- using native Bootstrap taps because of limitations
+with angular-ui tabsets. it always defaults to making the
+first tab active, so it can't be driven from the route
+https://github.com/angular-ui/bootstrap/issues/910 
+No JS is needed to drive the native tabs, since we're
+changing routes with each tab selection anyway.
+-->
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : tab == 'list'}">
+    <a href="./cat/bucket/batch_hold/list">
+        [% l('Hold Groups') %]
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'view'}">
+    <a href="./cat/bucket/batch_hold/view/{{bucketSvc.currentBucket.id()}}">
+        [% l('Current Users') %]
+        <span ng-cloak>({{bucketSvc.currentBucket.items().length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'pending'}">
+    <a href="./cat/bucket/batch_hold/pending/{{bucketSvc.currentBucket.id()}}">
+        [% l('Add Users') %]
+        <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'event'}">
+    <a href="./cat/bucket/batch_hold/event/{{bucketSvc.currentBucket.id()}}">
+        [% l('Hold Events') %]
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <!-- bucket info header -->
+    <div class="row" ng-show="tab != 'list'">
+      <div class="col-md-6">
+        [% INCLUDE 'staff/cat/bucket/batch_hold/t_bucket_info.tt2' %]
+      </div>
+    </div>
+
+    <!-- bucket not accessible warning -->
+    <div class="col-md-10 col-md-offset-1" ng-show="forbidden">
+      <div class="alert alert-warning">
+        [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %]
+      </div>
+    </div>
+
+    <div ng-view></div>
+  </div>
+</div>
+
+[% 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 (file)
index 0000000..bfa0f61
--- /dev/null
@@ -0,0 +1,21 @@
+
+<div ng-show="bucket()">
+  <strong>[% l('Hold Group #{{bucket().id()}}: {{bucket().name()}}') %]</strong>
+  <span ng-show="bucket().description()"> / {{bucket().description()}}</span>
+  <br/>
+  <span>
+    <ng-pluralize count="bucketSvc.currentBucket.items().length"
+      when="{'one': '[% l("1 user") %]', 'other': '[% l("{} users") %]'}">
+    </ng-pluralize>
+  </span>
+  <span> / [% l('Created {{bucket().create_time() | date:egDateAndTimeFormat}}') %]</span>
+  <span ng-show="bucket()._owner_name"> / 
+    {{bucket()._owner_name}} 
+    @ {{bucket()._owner_ou}}
+  </span>
+</div>
+
+<div ng-show="!bucket()">
+  <strong>[% l('No Hold Group Selected') %]</strong>
+</div>
+
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 (file)
index 0000000..58806bd
--- /dev/null
@@ -0,0 +1,42 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="allowAll,-display"
+  id-field="id"
+  idl-class="abhe"
+  auto-fields="true"
+  grid-controls="gridControls"
+  persist-key="cat.bucket.batch_hold.events">
+
+
+  <eg-grid-menu-item label="[% l('New Hold Group Event') %]" standalone="true"
+    handler="openCreateEventDialog"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Cancel hold group event') %]"
+    handler="rollbackEvent"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Title') %]" path="target" required visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['target']}}">
+      {{item['title']}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Author') %]">{{item['author']}}</eg-grid-field>
+  <eg-grid-field label="[% l('Create Date/Time') %]" path='run_date' visible></eg-grid-field>
+  <eg-grid-field label="[% l('# of holds placed') %]" path="mappings" required visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['target']}}/holds">
+      {{item['mappings'].length}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Staff') %]"     required path='staff.usrname'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" required path='hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Cancel Date/Time') %]" required path='cancelled' visible></eg-grid-field>
+
+</eg-grid>
+
+<div ng-show="failedPatronList.length" class="alert alert-warning">
+  <a ng-click="downloadFailed($event)"
+    download="{{csvExportFileName}}.csv" ng-href="{{csvExportURL}}">
+    <span class="glyphicon glyphicon-download"></span>
+      [% l('Download patron list for failed holds') %]
+  </a>
+</div>
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 (file)
index 0000000..3b965a0
--- /dev/null
@@ -0,0 +1,30 @@
+<!-- create event dialog -->
+
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('New Hold Group Event') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-event-target">[% l('Target Record') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-event-target" ng-model="args.target" placeholder="[% l('Record ID...') %]"/>
+      </div>
+      <div class="checkbox">
+        <label>
+          <input ng-model="args.override" type="checkbox"/>
+          [% l('Override all hold-blocking conditions possible?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Create Event') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..f23e59f
--- /dev/null
@@ -0,0 +1,20 @@
+
+<!-- global grid menu displayed on every Bucket page -->
+<eg-grid-menu-item label="[% l('New Hold Group') %]" standalone="true" 
+  handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Edit Hold Group') %]" 
+  handler="openEditBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Delete Hold Group') %]" 
+  handler="openDeleteBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Shared Hold Group') %]" 
+  handler="openSharedBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item divider="true"></eg-grid-menu-item>
+
+<eg-grid-menu-item ng-repeat="bkt in bucketSvc.allBuckets" 
+  label="{{bkt.name()}}" handler-data="bkt" 
+  handler="loadBucketFromMenu"></eg-grid-menu-item>
+
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 (file)
index 0000000..ea83402
--- /dev/null
@@ -0,0 +1,22 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="allowAll,-display"
+  id-field="id"
+  idl-class="cub"
+  auto-fields="true"
+  grid-controls="gridControls"
+  persist-key="cat.bucket.batch_hold.list">
+
+  <eg-grid-menu-item label="[% l('New Hold Group') %]" standalone="true"
+    handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Name') %]" path="name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Description') %]" path="description" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Owning Library') %]" path="owning_lib.shortname" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Patron Visible') %]" path="pub" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Owner') %]" path="owner.usrname"></eg-grid-field>
+  <eg-grid-field label="[% l('Container Type') %]" path="btype"></eg-grid-field>
+  <eg-grid-field label="[% l('Create Time') %]" path="create_time"></eg-grid-field>
+
+</eg-grid>
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 (file)
index 0000000..5e441d6
--- /dev/null
@@ -0,0 +1,58 @@
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="search()">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Scan Barcode') %]</span>
+        <input type="text" class="form-control" select-me="context.selectPendingBC"
+        ng-model="bucketSvc.barcodeString" placeholder="[% l('Barcode...') %]">
+      </div>
+    </form>
+  </div>
+  <div class="col-md-3"></div>
+  <div class="col-md-3">
+    <button class="btn btn-primary" ng-click="patron_search_dialog()">[% l('Search for patron') %]</button>
+  </div>
+</div>
+
+<div class="row pad-vert" ng-if="context.itemNotFound">
+  <div class="col-md-6">
+    <div class="alert alert-danger">
+      [% l('User Not Found') %]
+    </div>
+  </div>
+</div>
+
+<br/>
+
+<eg-grid
+  ng-hide="forbidden"
+  features="-sort,-multisort,-display"
+  id-field="id"
+  idl-class="au"
+  auto-fields="true"
+  grid-controls="gridControls"
+  items-provider="gridDataProvider"
+  persist-key="cat.bucket.batch_hold.pending">
+
+  <eg-grid-menu-item label="[% l('Add All To Hold Group') %]" standalone="true"
+    handler="addAllPending"></eg-grid-menu-item>
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Hold Group') %]" 
+    handler="addToBucket"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Clear List') %]" 
+    handler="resetPendingList"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Home Library') %]" path="home_ou.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Profile') %]"      path="profile.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('First Name') %]" path="first_given_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Last Name') %]" path="family_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"      path='card.barcode' visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/circ/patron/{{item['id']}}/holds">
+      {{item['card.barcode']}}
+    </a>
+  </eg-grid-field>
+
+</eg-grid>
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 (file)
index 0000000..45c0c4b
--- /dev/null
@@ -0,0 +1,29 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="allowAll,-display"
+  id-field="id"
+  idl-class="au"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Hold Groups') %]"
+  persist-key="cat.bucket.batch_hold.view">
+
+  [% INCLUDE 'staff/cat/bucket/batch_hold/t_grid_menu.tt2' %]
+
+  <eg-grid-action label="[% l('Remove Selected Users from Bucket') %]" group="[% l('Bucket') %]"
+    handler="detachUsers"></eg-grid-action>
+  <eg-grid-action label="[% l('Move Selected Users to Pending Users') %]" group="[% l('Bucket') %]"
+    handler="moveToPending"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Home Library') %]" path="home_ou.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Profile') %]"      path="profile.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('First Name') %]" path="first_given_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Last Name') %]" path="family_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"      path='card.barcode' visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/circ/patron/{{item['id']}}/holds">
+      {{item['card.barcode']}}
+    </a>
+  </eg-grid-field>
+
+</eg-grid>
index 56d29db..cb9ab95 100644 (file)
         <input type="text" class="form-control" id="edit-bucket-desc"
           ng-model="args.desc" placeholder="[% l('Description...') %]"/>
       </div>
-       <div class="checkbox">
+      <div ng-show="change_owner" class="form-group">
+        <label for="edit-bucket-owner">[% l('Owning Library') %]</label>
+        <eg-org-selector id="edit-bucket-owner" selected="args.owning_lib"></eg-org-selector>
+      </div>
+      <div class="checkbox">
         <label>
           <input ng-model="args.pub" type="checkbox"/>
-          [% l('Shareable') %]
+          <span ng-show="args.hold_sub">[% l('Visible to Patrons?') %]</span>
+          <span ng-show="!args.hold_sub">[% l('Shareable') %]</span>
         </label>
-        <eg-help-popover help-text="[%l('Visible and searchable by any staff member')%]">
+        <div ng-if="!args.hold_sub">
+          <eg-help-popover help-text="[%l('Visible and searchable by any staff member')%]">
+        </div>
       </div>
     </div>
     <div class="modal-footer">
index cbf60f6..4821fd3 100644 (file)
         <input type="text" class="form-control" id="edit-bucket-desc"
           ng-model="args.desc" placeholder="[% l('Description...') %]"/>
       </div>
+      <div ng-show="change_owner" class="form-group">
+        <label for="edit-bucket-owner">[% l('Owning Library') %]</label>
+        <eg-org-selector id="edit-bucket-owner" selected="args.owning_lib"></eg-org-selector>
+      </div>
        <div class="checkbox">
         <label>
           <input ng-model="args.pub" type="checkbox">
-          [% l('Shareable') %]
+          <span ng-show="args.hold_sub">[% l('Visible to Patrons?') %]</span>
+          <span ng-show="!args.hold_sub">[% l('Shareable') %]</span>
         </label>
-        <eg-help-popover help-text="[%l('Visible and searchable by any staff member')%]">
+        <div ng-if="!args.hold_sub">
+          <eg-help-popover help-text="[%l('Visible and searchable by any staff member')%]">
+        </div>
       </div>
     </div>
     <div class="modal-footer">
index 39cc783..9afa796 100644 (file)
@@ -79,7 +79,8 @@ angular.module('egCoreMod').run(['egStrings', function(s) {
   s.PAGE_TITLE_PATRON_EDIT = "[% l('Edit') %]";
   s.MERGE_SELF_NOT_ALLOWED = "[% l('Logged in account cannot be merged') %]";
   s.TEST_NOTIFY_SUCCESS = "[% l('Test Notification sent') %]";
-  s.TEST_NOTIFY_FAIL = "[% l('Test Notification failed to send') %]"
+  s.TEST_NOTIFY_FAIL = "[% l('Test Notification failed to send') %]";
+  s.REMOVE_HOLD_SUBSCRIPTIONS = "[% l('Remove selected Hold Groups for user?') %]";
 }]);
 </script>
 
@@ -194,6 +195,11 @@ angular.module('egCoreMod').run(['egStrings', function(s) {
             </a>
           </li>
           <li>
+            <a href="./circ/patron/{{patron().id()}}/hold_subscriptions">
+              [% l('Hold Groups') %]
+            </a>
+          </li>
+          <li>
             <a href="./circ/patron/{{patron().id()}}/surveys">
               [% l('Surveys') %]
             </a>
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 (file)
index 0000000..a33c935
--- /dev/null
@@ -0,0 +1,21 @@
+<div class="strong-text-2">[% l('Hold Groups') %]</div>
+
+<eg-grid
+  features="allowAll,-display"
+  id-field="id"
+  idl-class="cub"
+  auto-fields="true"
+  grid-controls="gridControls"
+  items-provider="gridDataProvider"
+  persist-key="circ.patron.batch_hold.list">
+
+<eg-grid-action label="[% l('Remove Hold Groups') %]"
+  handler="removeSubscriptions"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Name') %]" path="name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Description') %]" path="description" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Public') %]" path="pub" visible></eg-grid-field>
+
+</eg-grid>
+
index f42ba4a..6d4e028 100644 (file)
     label="{{bkt.name()}}" handler-data="bkt"
     handler="addToBucket" disabled="need_one_selected"></eg-grid-menu-item>
 
+  <eg-grid-menu-item ng-repeat="bkt in bucketSvc.allSubscriptions"
+    label="[% 'Hold Groups: ' %] {{bkt.name()}}" handler-data="bkt"
+    handler="addToBucket" disabled="need_one_selected"></eg-grid-menu-item>
+
   <eg-grid-field label="[% l('ID') %]" path='id' visible></eg-grid-field>
   <eg-grid-field label="[% l('Card') %]" path='card.barcode' visible>
     <a href="./circ/patron/{{item.id()}}/checkout">{{item.card().barcode()}}</a>
index e9c2d64..ddaa169 100644 (file)
               [% l('User Buckets') %]
             </a>
           </li>
+          <li>
+            <a href="./cat/bucket/batch_hold/list" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('Hold Groups') %]
+            </a>
+          </li>
           <li class="divider"></li>
           <li>
             <a href="./circ/patron/credentials" target="_self">
index d794f61..525b8d5 100644 (file)
@@ -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 (file)
index 0000000..d34b986
--- /dev/null
@@ -0,0 +1,889 @@
+/**
+ * Hold Group (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);
+}])
+
index f602398..736c938 100644 (file)
@@ -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'});
 })
 
@@ -629,6 +635,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;
@@ -643,7 +650,7 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore ,
                     return egCore.net.request(
                         'open-ils.actor',
                         'open-ils.actor.container.item.create',
-                        egCore.auth.token(), 'user', item
+                        egCore.auth.token(), 'user', item, 1
                     );
                 }).then(
                     function(){ added_count++ },
@@ -963,6 +970,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',
index e391874..2c3d355 100644 (file)
@@ -93,7 +93,7 @@ function($scope,  $location,  $q,  $timeout,  $uibModal,
                 egCore.net.request(
                     'open-ils.actor',
                     'open-ils.actor.container.item.create', 
-                    egCore.auth.token(), 'user', item
+                    egCore.auth.token(), 'user', item, 1
                 ).then(function(resp) {
 
                     // HACK: add the IDs of the added items so that the size
index 487c385..f7ff64e 100644 (file)
@@ -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 groups
         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();
diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/hold-subscriptions.adoc b/docs/RELEASE_NOTES_NEXT/Circulation/hold-subscriptions.adoc
new file mode 100644 (file)
index 0000000..13277e6
--- /dev/null
@@ -0,0 +1,26 @@
+Hold Groups
+^^^^^^^^^^^
+
+This feature allows staff to add multiple users to a named hold group
+bucket and place title-level holds for a record for that entire set of users.
+Users can be added to such a hold group bucket from either the patron
+search result interface, via the Add to Bucket dropdown, or through a dedicated
+Hold Group interface available from the Circulation menu.  Adding new
+patrons to a hold group bucket will require staff have the PLACE_HOLD
+permission.
+
+Holds can be placed for the users in a hold group bucket either directly from
+the normal staff-place hold interface in the embedded OPAC, or by supplying the
+record ID within the hold group 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
+hold group bucket hold will requires staff have the MANAGE_HOLD_GROUPS
+permission, which is new with this development.
+
+In the event of a mistaken hold group hold, staff with the MANAGE_HOLD_GROUPS
+permission will have the ability to cancel all unfulfilled holds created as
+part of a hold group event.
+
+A link to the title's hold interface is available from the list of hold group
+events in the dedicated hold group interface.
+