From: Mike Rylander Date: Thu, 2 Feb 2017 20:29:46 +0000 (-0500) Subject: LP#1689608: Batch user editing X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=b3e2b84ec99c8c6fafdc6e2e2051679725c10734;p=working%2FEvergreen.git LP#1689608: Batch user editing Summary ------- Currently, editing and deleting of users must be performed on a user-by-user basis. There are workflows that would benefit from the ability to act on a set of users, where the changes to all users in the set are the same. This commit provides a new interface analogous to the Copy Bucket interface to record the selection and grouping of a set of users into a User Bucket. The addition of users to a User Bucket is possible from the Patron Search interface by the use of a new grid Action, and directly on the User Bucket interface by user barcode. It is also possible to add users by uploading a text file that contains a list of user barcodes. From this interface it is possible to perform a set of specific batch update operations against users generally. Editing users ------------- In order to facilitate the update of user data fields, specifically: * Active flag * Primary Permission Group (group application permissions consulted) * Juvenile flag * Home Library (UPDATE_USER checked against both old and new value) * Privilege Expiration Date * Barred flag (BAR_PATRON permission consulted) * Internet Access Level This commit contains a new set of business logic allowing staff to supply new values for these fields. Creation and immediate processing of a change set will be made available through a grid Menu item. If the staff user does not have the UPDATE_USER permission, this option will be disabled. Each change set requires a name. Buckets may have multiple change sets. All users in the Bucket at the time of processing will be updated when the change set is processed, and change sets are processed immediately upon successful creation. The interface will deliver progress information regarding the processing stage and percent of completion. While processing the users, the original value for each field edited will be recorded for potential future rollback. Users can examine the success and failure of applied change sets. The user will be able to rollback the entire change set, but not parts thereof. The rollback will affect only those users that were successfully updated by the original change set and may be different from the current set of users in the Bucket. Users can manually discard change sets, removing them from the interface but preventing future rollback. As a batch process, rather than a direct edit, this mechanism explicitly skips processing of Action/Trigger event definitions for user update. Deleting users -------------- In order to facilitate the batch deletion of users, this commit creates a new set of business logic allowing staff to set the Deleted flag on users. Creation and immediate processing of a batch delete is made available through a grid Menu item. If the staff user does not have both the UPDATE_USER and DELETE_USER permission, this option is disabled. Because of the potential for damage and the additional required permission, this field change is specifically segregated from the general Editing functionally described above. Each delete set requires a name. Buckets may have multiple delete sets. All users in the Bucket at the time of processing will be marked as deleted when the delete set is processed. The interface will deliver progress information regarding the processing stage and percent of completion. While processing the users, the original value for the "deleted" field will be recorded for potential future rollback. Users will be able to examine the success and failure of applied delete sets in the same interface used for the above described change sets. As a batch process, rather than a direct edit, this mechanism explicitly skips processing of Action/Trigger event definitions for user deletion. This mechanism does not use the Purge User functionality, but instead simply marks the users as deleted. Future enhancement could add such functionality. Editing Statistical Category Entries ------------------------------------ In order to facilitate the batch editing, addition, and removal of Statistical Category Entries for users, this commit creates a new set of business logic allowing staff to either remove or add & update Entries for Statistical Categories to which the staff member has access. Processing of Statistical Category Entry modifications will are available through a grid Menu item. All users in the bucket will have their Statistical Category Entries modified. Unlike user data field updates, modification of Statistical Category Entries is permanent and cannot be rolled back. No named change sets are required. The interface will deliver progress information regarding the processing stage and percent of completion. As a batch process, rather than a direct edit, this mechanism explicitly skips processing of Action/Trigger event definitions for user update. New service requirement ----------------------- This new functionality makes use of the QStore service, which was previously unused in production. If this service has been removed from the configuration of a live Evergreen instances, it will need to be added back in order for batch user editing to succeed. Signed-off-by: Mike Rylander Signed-off-by: Scott Thomas Signed-off-by: Galen Charlton Conflicts: Open-ILS/src/templates/staff/circ/patron/index.tt2 Open-ILS/web/js/ui/default/staff/circ/patron/app.js --- diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index f2abaa798f..e0d75840fd 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -2418,7 +2418,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - + @@ -2432,6 +2432,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + + + + + + + @@ -4730,7 +4737,7 @@ SELECT usr, - + @@ -4744,6 +4751,13 @@ SELECT usr, + + + + + + + @@ -4755,7 +4769,7 @@ SELECT usr, - + @@ -4765,11 +4779,21 @@ SELECT usr, + + + + + + + + + + @@ -6298,7 +6322,7 @@ SELECT usr, - + @@ -6308,11 +6332,21 @@ SELECT usr, + + + + + + + + + + @@ -6423,7 +6457,7 @@ SELECT usr, - + @@ -6433,11 +6467,21 @@ SELECT usr, + + + + + + + + + + @@ -6683,7 +6727,7 @@ SELECT usr, - + @@ -6697,6 +6741,13 @@ SELECT usr, + + + + + + + @@ -7213,7 +7264,7 @@ SELECT usr, - + @@ -7223,11 +7274,21 @@ SELECT usr, + + + + + + + + + + @@ -7394,7 +7455,7 @@ SELECT usr, - + @@ -7408,6 +7469,13 @@ SELECT usr, + + + + + + + @@ -9761,7 +9829,35 @@ SELECT usr, - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -9774,15 +9870,26 @@ SELECT usr, + + + + + + + + + + + - + @@ -9792,6 +9899,22 @@ SELECT usr, + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm index dada935dbb..badb13482d 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm @@ -22,14 +22,53 @@ my $svc = 'open-ils.cstore'; my $meth = 'open-ils.cstore.direct.container'; my %types; my %ctypes; +my %itypes; +my %htypes; +my %qtypes; +my %ttypes; +my %batch_perm; +my %table; + +$batch_perm{'biblio'} = ['UPDATE_MARC']; +$batch_perm{'callnumber'} = ['UPDATE_VOLUME']; +$batch_perm{'copy'} = ['UPDATE_COPY']; +$batch_perm{'user'} = ['UPDATE_USER']; + $types{'biblio'} = "$meth.biblio_record_entry_bucket"; $types{'callnumber'} = "$meth.call_number_bucket"; $types{'copy'} = "$meth.copy_bucket"; $types{'user'} = "$meth.user_bucket"; + $ctypes{'biblio'} = "container_biblio_record_entry_bucket"; $ctypes{'callnumber'} = "container_call_number_bucket"; $ctypes{'copy'} = "container_copy_bucket"; $ctypes{'user'} = "container_user_bucket"; + +$itypes{'biblio'} = "biblio_record_entry"; +$itypes{'callnumber'} = "asset_call_number"; +$itypes{'copy'} = "asset_copy"; +$itypes{'user'} = "actor_user"; + +$ttypes{'biblio'} = "biblio_record_entry"; +$ttypes{'callnumber'} = "call_number"; +$ttypes{'copy'} = "copy"; +$ttypes{'user'} = "user"; + +$htypes{'biblio'} = "bre"; +$htypes{'callnumber'} = "acn"; +$htypes{'copy'} = "acp"; +$htypes{'user'} = "au"; + +$table{'biblio'} = "biblio.record_entry"; +$table{'callnumber'} = "asset.call_number"; +$table{'copy'} = "asset.copy"; +$table{'user'} = "actor.usr"; + +#$qtypes{'biblio'} = 0 +#$qtypes{'callnumber'} = 0; +#$qtypes{'copy'} = 0; +$qtypes{'user'} = 1; + my $event; sub _sort_buckets { @@ -648,6 +687,467 @@ sub anon_cache { } } +sub batch_statcat_apply { + my $self = shift; + my $client = shift; + my $ses = shift; + my $c_id = shift; + my $changes = shift; + + # $changes is a hashref that looks like: + # { + # remove => [ qw/ stat cat ids to remove / ], + # apply => { $statcat_id => $value_string, ... } + # } + + my $class = 'user'; + my $max = 0; + my $count = 0; + my $stage = 0; + + my $e = new_editor(xact=>1, authtoken=>$ses); + return $e->die_event unless $e->checkauth; + $client->respond({ ord => $stage++, stage => 'CONTAINER_BATCH_UPDATE_PERM_CHECK' }); + return $e->die_event unless $e->allowed('CONTAINER_BATCH_UPDATE'); + + my $meth = 'retrieve_' . $ctypes{$class}; + my $bkt = $e->$meth($c_id) or return $e->die_event; + + unless($bkt->owner eq $e->requestor->id) { + $client->respond({ ord => $stage++, stage => 'CONTAINER_PERM_CHECK' }); + my $owner = $e->retrieve_actor_user($bkt->owner) + or return $e->die_event; + return $e->die_event unless ( + $e->allowed('VIEW_CONTAINER', $bkt->owning_lib) || $e->allowed('VIEW_CONTAINER', $owner->home_ou) + ); + } + + $meth = 'search_' . $ctypes{$class} . '_item'; + my $contents = $e->$meth({bucket => $c_id}); + + if ($self->{perms}) { + $max = scalar(@$contents); + $client->respond({ ord => $stage, max => $max, count => 0, stage => 'ITEM_PERM_CHECK' }); + for my $item (@$contents) { + $count++; + $meth = 'retrieve_' . $itypes{$class}; + my $field = 'target_'.$ttypes{$class}; + my $obj = $e->$meth($item->$field); + + for my $perm_field (keys %{$self->{perms}}) { + my $perm_def = $self->{perms}->{$perm_field}; + my ($pwhat,$pwhere) = ([split ' ', $perm_def], $perm_field); + for my $p (@$pwhat) { + $e->allowed($p, $obj->$pwhere) or return $e->die_event; + } + } + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'ITEM_PERM_CHECK' }); + } + $stage++; + } + + my @users = map { $_->target_user } @$contents; + $max = scalar(@users) * scalar(@{$changes->{remove}}); + $count = 0; + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'STAT_CAT_REMOVE' }); + + my $chunk = int($max / 10) || 1; + my $to_remove = $e->search_actor_stat_cat_entry_user_map({ target_usr => \@users, stat_cat => $changes->{remove} }); + for my $t (@$to_remove) { + $e->delete_actor_stat_cat_entry_user_map($t); + $count++; + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'STAT_CAT_REMOVE' }) + unless ($count % $chunk); + } + + $stage++; + + $max = scalar(@users) * scalar(keys %{$changes->{apply}}); + $count = 0; + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'STAT_CAT_APPLY' }); + + $chunk = int($max / 10) || 1; + for my $item (@$contents) { + for my $astatcat (keys %{$changes->{apply}}) { + my $new_value = $changes->{apply}->{$astatcat}; + my $to_change = $e->search_actor_stat_cat_entry_user_map({ target_usr => $item->target_user, stat_cat => $astatcat }); + if (@$to_change) { + $to_change = $$to_change[0]; + $to_change->stat_cat_entry($new_value); + $e->update_actor_stat_cat_entry_user_map($to_change); + } else { + $to_change = new Fieldmapper::actor::stat_cat_entry_user_map; + $to_change->stat_cat_entry($new_value); + $to_change->stat_cat($astatcat); + $to_change->target_usr($item->target_user); + $e->create_actor_stat_cat_entry_user_map($to_change); + } + $count++; + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'STAT_CAT_APPLY' }) + unless ($count % $chunk); + } + } + + $e->commit; + + return { stage => 'COMPLETE' }; +} + +__PACKAGE__->register_method( + method => "batch_statcat_apply", + api_name => "open-ils.actor.container.user.batch_statcat_apply", + ctype => 'user', + perms => { + home_ou => 'UPDATE_USER', # field -> perm means "test this perm with field as context OU", both old and new + }, + fields => [ qw/active profile juvenile home_ou expire_date barred net_access_level/ ], + signature => { + desc => 'Edits allowed fields on users in a bucket', + params => [{ + desc => 'Session key', type => 'string', + desc => 'User container id', + desc => 'Hash of statcats to apply or remove', type => 'hash', + }], + return => { + desc => 'Object with the structure { stage => "stage string", max => max_for_stage, count => count_in_stage }', + type => 'hash' + } + } +); + + +sub apply_rollback { + my $self = shift; + my $client = shift; + my $ses = shift; + my $c_id = shift; + my $main_fsg = shift; + + my $max = 0; + my $count = 0; + my $stage = 0; + + my $class = $self->{ctype} or return undef; + + my $e = new_editor(xact=>1, authtoken=>$ses); + return $e->die_event unless $e->checkauth; + + for my $bp (@{$batch_perm{$class}}) { + return { stage => 'COMPLETE' } unless $e->allowed($bp); + } + + $client->respond({ ord => $stage++, stage => 'CONTAINER_BATCH_UPDATE_PERM_CHECK' }); + return $e->die_event unless $e->allowed('CONTAINER_BATCH_UPDATE'); + + my $meth = 'retrieve_' . $ctypes{$class}; + my $bkt = $e->$meth($c_id) or return $e->die_event; + + unless($bkt->owner eq $e->requestor->id) { + $client->respond({ ord => $stage++, stage => 'CONTAINER_PERM_CHECK' }); + my $owner = $e->retrieve_actor_user($bkt->owner) + or return $e->die_event; + return $e->die_event unless ( + $e->allowed('VIEW_CONTAINER', $bkt->owning_lib) || $e->allowed('VIEW_CONTAINER', $owner->home_ou) + ); + } + + $main_fsg = $e->retrieve_action_fieldset_group($main_fsg); + return { stage => 'COMPLETE', error => 'No field set group' } unless $main_fsg; + + my $rbg = $e->retrieve_action_fieldset_group($main_fsg->rollback_group); + return { stage => 'COMPLETE', error => 'No rollback field set group' } unless $rbg; + + my $fieldsets = $e->search_action_fieldset({fieldset_group => $rbg->id}); + $max = scalar(@$fieldsets); + + $client->respond({ ord => $stage, max => $max, count => 0, stage => 'APPLY_EDITS' }); + for my $fs (@$fieldsets) { + my $res = $e->json_query({ + from => ['action.apply_fieldset', $fs->id, $table{$class}, 'id', undef] + })->[0]->{'action.apply_fieldset'}; + + $client->respond({ + ord => $stage, + max => $max, + count => ++$count, + stage => 'APPLY_EDITS', + error => $res ? "Could not apply fieldset ".$fs->id.": $res" : undef + }); + } + + $main_fsg->rollback_time('now'); + $e->update_action_fieldset_group($main_fsg); + + $e->commit; + + return { stage => 'COMPLETE' }; +} +__PACKAGE__->register_method( + method => "apply_rollback", + max_bundle_count => 1, + api_name => "open-ils.actor.container.user.apply_rollback", + ctype => 'user', + signature => { + desc => 'Applys rollback of a fieldset group to users in a bucket', + params => [ + { desc => 'Session key', type => 'string' }, + { desc => 'User container id', type => 'number' }, + { desc => 'Main (non-rollback) fieldset group' }, + ], + return => { + desc => 'Object with the structure { fieldset_group => $id, stage => "COMPLETE", error => ("error string if any"|undef if none) }', + type => 'hash' + } + } +); + + +sub batch_edit { + my $self = shift; + my $client = shift; + my $ses = shift; + my $c_id = shift; + my $edit_name = shift; + my $edits = shift; + + my $max = 0; + my $count = 0; + my $stage = 0; + + my $class = $self->{ctype} or return undef; + + my $e = new_editor(xact=>1, authtoken=>$ses); + return $e->die_event unless $e->checkauth; + + for my $bp (@{$batch_perm{$class}}) { + return { stage => 'COMPLETE' } unless $e->allowed($bp); + } + + $client->respond({ ord => $stage++, stage => 'CONTAINER_BATCH_UPDATE_PERM_CHECK' }); + return $e->die_event unless $e->allowed('CONTAINER_BATCH_UPDATE'); + + my $meth = 'retrieve_' . $ctypes{$class}; + my $bkt = $e->$meth($c_id) or return $e->die_event; + + unless($bkt->owner eq $e->requestor->id) { + $client->respond({ ord => $stage++, stage => 'CONTAINER_PERM_CHECK' }); + my $owner = $e->retrieve_actor_user($bkt->owner) + or return $e->die_event; + return $e->die_event unless ( + $e->allowed('VIEW_CONTAINER', $bkt->owning_lib) || $e->allowed('VIEW_CONTAINER', $owner->home_ou) + ); + } + + $meth = 'search_' . $ctypes{$class} . '_item'; + my $contents = $e->$meth({bucket => $c_id}); + + $max = 0; + $max = scalar(@$contents) if ($self->{perms}); + $max += scalar(@$contents) if ($self->{base_perm}); + + my $obj_cache = {}; + if ($self->{base_perm}) { + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'ITEM_PERM_CHECK' }); + for my $item (@$contents) { + $count++; + $meth = 'retrieve_' . $itypes{$class}; + my $field = 'target_'.$ttypes{$class}; + my $obj = $$obj_cache{$item->$field} = $e->$meth($item->$field); + + for my $perm_field (keys %{$self->{base_perm}}) { + my $perm_def = $self->{base_perm}->{$perm_field}; + my ($pwhat,$pwhere) = ([split ' ', $perm_def], $perm_field); + for my $p (@$pwhat) { + $e->allowed($p, $obj->$pwhere) or return $e->die_event; + if ($$edits{$pwhere}) { + $e->allowed($p, $$edits{$pwhere}) or do { + $logger->warn("Cannot update $class ".$obj->id.", $pwhat at $pwhere not allowed."); + return $e->die_event; + }; + } + } + } + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'ITEM_PERM_CHECK' }); + } + } + + if ($self->{perms}) { + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'ITEM_PERM_CHECK' }); + for my $item (@$contents) { + $count++; + $meth = 'retrieve_' . $itypes{$class}; + my $field = 'target_'.$ttypes{$class}; + my $obj = $$obj_cache{$item->$field} || $e->$meth($item->$field); + + for my $perm_field (keys %{$self->{perms}}) { + my $perm_def = $self->{perms}->{$perm_field}; + if (ref($perm_def) eq 'HASH') { # we care about specific values being set + for my $perm_value (keys %$perm_def) { + if (exists $$edits{$perm_field} && $$edits{$perm_field} eq $perm_value) { # check permission + while (my ($pwhat,$pwhere) = each %{$$perm_def{$perm_value}}) { + if ($pwhere eq '*') { + $pwhere = undef; + } else { + $pwhere = $obj->$pwhere; + } + $pwhat = [ split / /, $pwhat ]; + for my $p (@$pwhat) { + $e->allowed($p, $pwhere) or do { + $pwhere ||= "everywhere"; + $logger->warn("Cannot update $class ".$obj->id.", $pwhat at $pwhere not allowed."); + return $e->die_event; + }; + } + } + } + } + } elsif (ref($perm_def) eq 'CODE') { # we need to run the code on old and new, and pass both tests + if (exists $$edits{$perm_field}) { + $perm_def->($e, $obj->$perm_field) or return $e->die_event; + $perm_def->($e, $$edits{$perm_field}) or return $e->die_event; + } + } else { # we're checking an ou field + my ($pwhat,$pwhere) = ([split ' ', $perm_def], $perm_field); + if ($$edits{$pwhere}) { + for my $p (@$pwhat) { + $e->allowed($p, $obj->$pwhere) or return $e->die_event; + $e->allowed($p, $$edits{$pwhere}) or do { + $logger->warn("Cannot update $class ".$obj->id.", $pwhat at $pwhere not allowed."); + return $e->die_event; + }; + } + } + } + } + $client->respond({ ord => $stage, max => $max, count => $count, stage => 'ITEM_PERM_CHECK' }); + } + $stage++; + } + + $client->respond({ ord => $stage++, stage => 'FIELDSET_GROUP_CREATE' }); + my $fsgroup = Fieldmapper::action::fieldset_group->new; + $fsgroup->isnew(1); + $fsgroup->name($edit_name); + $fsgroup->creator($e->requestor->id); + $fsgroup->owning_lib($e->requestor->ws_ou); + $fsgroup->container($c_id); + $fsgroup->container_type($ttypes{$class}); + $fsgroup = $e->create_action_fieldset_group($fsgroup); + + $client->respond({ ord => $stage++, stage => 'FIELDSET_CREATE' }); + my $fieldset = Fieldmapper::action::fieldset->new; + $fieldset->isnew(1); + $fieldset->fieldset_group($fsgroup->id); + $fieldset->owner($e->requestor->id); + $fieldset->owning_lib($e->requestor->ws_ou); + $fieldset->status('PENDING'); + $fieldset->classname($htypes{$class}); + $fieldset->name($edit_name . ' batch group fieldset'); + $fieldset->stored_query($qtypes{$class}); + $fieldset = $e->create_action_fieldset($fieldset); + + my @keys = keys %$edits; + $max = scalar(@keys); + $count = 0; + $client->respond({ ord => $stage, count=> $count, max => $max, stage => 'FIELDSET_EDITS_CREATE' }); + for my $key (@keys) { + if ($self->{fields}) { # restrict edits to registered fields + next unless (grep { $_ eq $key } @{$self->{fields}}); + } + my $fs_cv = Fieldmapper::action::fieldset_col_val->new; + $fs_cv->isnew(1); + $fs_cv->fieldset($fieldset->id); + $fs_cv->col($key); + $fs_cv->val($$edits{$key}); + $e->create_action_fieldset_col_val($fs_cv); + $count++; + $client->respond({ ord => $stage, count=> $count, max => $max, stage => 'FIELDSET_EDITS_CREATE' }); + } + + $client->respond({ ord => ++$stage, stage => 'CONSTRUCT_QUERY' }); + my $qstore = OpenSRF::AppSession->connect('open-ils.qstore'); + my $prep = $qstore->request('open-ils.qstore.prepare', $fieldset->stored_query)->gather(1); + my $token = $prep->{token}; + $qstore->request('open-ils.qstore.bind_param', $token, {bucket => $c_id})->gather(1); + my $sql = $qstore->request('open-ils.qstore.sql', $token)->gather(1); + $sql =~ s/\n\s*/ /g; # normalize the string + $sql =~ s/;\s*//g; # kill trailing semicolon + + $client->respond({ ord => ++$stage, stage => 'APPLY_EDITS' }); + my $res = $e->json_query({ + from => ['action.apply_fieldset', $fieldset->id, $table{$class}, 'id', $sql] + })->[0]->{'action.apply_fieldset'}; + + $e->commit; + $qstore->disconnect; + + return { fieldset_group => $fsgroup->id, stage => 'COMPLETE', error => $res }; +} + +__PACKAGE__->register_method( + method => "batch_edit", + max_bundle_count => 1, + api_name => "open-ils.actor.container.user.batch_edit", + ctype => 'user', + base_perm => { home_ou => 'UPDATE_USER' }, + perms => { + profile => sub { + my ($e, $group) = @_; + my $g = $e->retrieve_permission_grp_tree($group); + if (my $p = $g->application_perm()) { + return $e->allowed($p); + } + return 1; + }, # code ref is run with params (editor,value), for both old and new value + # home_ou => 'UPDATE_USER', # field -> perm means "test this perm with field as context OU", both old and new + barred => { + t => { BAR_PATRON => 'home_ou' }, + f => { UNBAR_PATRON => 'home_ou' } + } # field -> struct means "if field getting value "key" check -> perm -> at context org, both old and new + }, + fields => [ qw/active profile juvenile home_ou expire_date barred net_access_level/ ], + signature => { + desc => 'Edits allowed fields on users in a bucket', + params => [ + { desc => 'Session key', type => 'string' }, + { desc => 'User container id', type => 'number' }, + { desc => 'Batch edit name', type => 'string' }, + { desc => 'Edit hash, key is column, value is new value to apply', type => 'hash' }, + ], + return => { + desc => 'Object with the structure { fieldset_group => $id, stage => "COMPLETE", error => ("error string if any"|undef if none) }', + type => 'hash' + } + } +); + +__PACKAGE__->register_method( + method => "batch_edit", + api_name => "open-ils.actor.container.user.batch_delete", + ctype => 'user', + perms => { + deleted => { + t => { 'DELETE_USER UPDATE_USER' => 'home_ou' }, + f => { 'UPDATE_USER' => 'home_ou' } + } + }, + fields => [ qw/deleted/ ], + signature => { + desc => 'Deletes users in a bucket', + params => [{ + { desc => 'Session key', type => 'string' }, + { desc => 'User container id', type => 'number' }, + { desc => 'Batch delete name', type => 'string' }, + { desc => 'Edit delete, key is "deleted", value is new value to apply ("t")', type => 'hash' }, + + }], + return => { + desc => 'Object with the structure { fieldset_group => $id, stage => "COMPLETE", error => ("error string if any"|undef if none) }', + type => 'hash' + } + } +); + 1; diff --git a/Open-ILS/src/sql/Pg/008.schema.query.sql b/Open-ILS/src/sql/Pg/008.schema.query.sql index 1dc97b12b6..8b8fd896c4 100644 --- a/Open-ILS/src/sql/Pg/008.schema.query.sql +++ b/Open-ILS/src/sql/Pg/008.schema.query.sql @@ -1160,4 +1160,27 @@ CREATE OR REPLACE RULE query_expr_xsubq_delete_rule AS DO INSTEAD DELETE FROM query.expression WHERE id = OLD.id; +INSERT INTO query.bind_variable (name,type,description,label) + SELECT 'bucket','number','ID of the bucket to pull items from','Bucket ID'; + +-- Assumes completely empty 'query' schema +INSERT INTO query.stored_query (type, use_distinct) VALUES ('SELECT', TRUE); -- 1 + +INSERT INTO query.from_relation (type, table_name, class_name, table_alias) VALUES ('RELATION', 'container.user_bucket_item', 'cubi', 'cubi'); -- 1 +UPDATE query.stored_query SET from_clause = 1; + +INSERT INTO query.expr_xcol (table_alias, column_name) VALUES ('cubi', 'target_user'); -- 1 +INSERT INTO query.select_item (stored_query,seq_no,expression) VALUES (1,1,1); + +INSERT INTO query.expr_xcol (table_alias, column_name) VALUES ('cubi', 'bucket'); -- 2 +INSERT INTO query.expr_xbind (bind_variable) VALUES ('bucket'); -- 3 + +INSERT INTO query.expr_xop (left_operand, operator, right_operand) VALUES (2, '=', 3); -- 4 +UPDATE query.stored_query SET where_clause = 4; + +SELECT SETVAL('query.stored_query_id_seq', 1000, TRUE) FROM query.stored_query; +SELECT SETVAL('query.from_relation_id_seq', 1000, TRUE) FROM query.from_relation; +SELECT SETVAL('query.expression_id_seq', 10000, TRUE) FROM query.expression; + + COMMIT; diff --git a/Open-ILS/src/sql/Pg/070.schema.container.sql b/Open-ILS/src/sql/Pg/070.schema.container.sql index 0e21c5fda4..32dfc6aae3 100644 --- a/Open-ILS/src/sql/Pg/070.schema.container.sql +++ b/Open-ILS/src/sql/Pg/070.schema.container.sql @@ -37,6 +37,7 @@ CREATE TABLE container.copy_bucket ( btype TEXT NOT NULL DEFAULT 'misc' REFERENCES container.copy_bucket_type (code) DEFERRABLE INITIALLY DEFERRED, description TEXT, pub BOOL NOT NULL DEFAULT FALSE, + owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), CONSTRAINT cb_name_once_per_owner UNIQUE (owner,name,btype) ); @@ -91,6 +92,7 @@ CREATE TABLE container.call_number_bucket ( btype TEXT NOT NULL DEFAULT 'misc' REFERENCES container.call_number_bucket_type (code) DEFERRABLE INITIALLY DEFERRED, description TEXT, pub BOOL NOT NULL DEFAULT FALSE, + owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), CONSTRAINT cnb_name_once_per_owner UNIQUE (owner,name,btype) ); @@ -146,6 +148,7 @@ CREATE TABLE container.biblio_record_entry_bucket ( btype TEXT NOT NULL DEFAULT 'misc' REFERENCES container.biblio_record_entry_bucket_type (code) DEFERRABLE INITIALLY DEFERRED, description TEXT, pub BOOL NOT NULL DEFAULT FALSE, + owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), CONSTRAINT breb_name_once_per_owner UNIQUE (owner,name,btype) ); @@ -199,6 +202,7 @@ CREATE TABLE container.user_bucket ( btype TEXT NOT NULL DEFAULT 'misc' REFERENCES container.user_bucket_type (code) DEFERRABLE INITIALLY DEFERRED, description TEXT, pub BOOL NOT NULL DEFAULT FALSE, + owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), CONSTRAINT ub_name_once_per_owner UNIQUE (owner,name,btype) ); diff --git a/Open-ILS/src/sql/Pg/090.schema.action.sql b/Open-ILS/src/sql/Pg/090.schema.action.sql index 18604b8a2d..806814d55f 100644 --- a/Open-ILS/src/sql/Pg/090.schema.action.sql +++ b/Open-ILS/src/sql/Pg/090.schema.action.sql @@ -809,8 +809,23 @@ CREATE TRIGGER action_hold_request_aging_tgr FOR EACH ROW EXECUTE PROCEDURE action.age_hold_on_delete (); +CREATE TABLE action.fieldset_group ( + id SERIAL PRIMARY KEY, + name NEXT NOT NULL, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + complete_time TIMESTAMPTZ, + container INT, -- Points to a container of some type ... + container_type TEXT, -- One of 'biblio_record_entry', 'user', 'call_number', 'copy' + rollback_group INT REFERENCES action.fieldset_group (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + rollback_time TIMESTAMPTZ, + creator INT NOT NULL REFERENCES actor.usr (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + owning_lib INT NOT NULL REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED +); + CREATE TABLE action.fieldset ( id SERIAL PRIMARY KEY, + fieldset_group INT REFERENCES action.fieldset_group (id) + DEFERRABLE INITIALLY DEFERRED, owner INT NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED, owning_lib INT NOT NULL REFERENCES actor.org_unit (id) @@ -823,6 +838,7 @@ CREATE TABLE action.fieldset ( applied_time TIMESTAMPTZ, classname TEXT NOT NULL, -- an IDL class name name TEXT NOT NULL, + error_msg TEXT, stored_query INT REFERENCES query.stored_query (id) DEFERRABLE INITIALLY DEFERRED, pkey_value TEXT, @@ -1241,113 +1257,170 @@ END; $func$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION action.apply_fieldset( - fieldset_id IN INT, -- id from action.fieldset - table_name IN TEXT, -- table to be updated - pkey_name IN TEXT, -- name of primary key column in that table - query IN TEXT -- query constructed by qstore (for query-based - -- fieldsets only; otherwise null + fieldset_id IN INT, -- id from action.fieldset + table_name IN TEXT, -- table to be updated + pkey_name IN TEXT, -- name of primary key column in that table + query IN TEXT -- query constructed by qstore (for query-based + -- fieldsets only; otherwise null ) RETURNS TEXT AS $$ DECLARE - statement TEXT; - fs_status TEXT; - fs_pkey_value TEXT; - fs_query TEXT; - sep CHAR; - status_code TEXT; - msg TEXT; - update_count INT; - cv RECORD; + statement TEXT; + where_clause TEXT; + fs_status TEXT; + fs_pkey_value TEXT; + fs_query TEXT; + sep CHAR; + status_code TEXT; + msg TEXT; + fs_id INT; + fsg_id INT; + update_count INT; + cv RECORD; + fs_obj action.fieldset%ROWTYPE; + fs_group action.fieldset_group%ROWTYPE; + rb_row RECORD; BEGIN - -- Sanity checks - IF fieldset_id IS NULL THEN - RETURN 'Fieldset ID parameter is NULL'; - END IF; - IF table_name IS NULL THEN - RETURN 'Table name parameter is NULL'; - END IF; - IF pkey_name IS NULL THEN - RETURN 'Primary key name parameter is NULL'; - END IF; - -- - statement := 'UPDATE ' || table_name || ' SET'; - -- - SELECT - status, - quote_literal( pkey_value ) - INTO - fs_status, - fs_pkey_value - FROM - action.fieldset - WHERE - id = fieldset_id; - -- - IF fs_status IS NULL THEN - RETURN 'No fieldset found for id = ' || fieldset_id; - ELSIF fs_status = 'APPLIED' THEN - RETURN 'Fieldset ' || fieldset_id || ' has already been applied'; - END IF; - -- - sep := ''; - FOR cv IN - SELECT col, - val - FROM action.fieldset_col_val - WHERE fieldset = fieldset_id - LOOP - statement := statement || sep || ' ' || cv.col - || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' ); - sep := ','; - END LOOP; - -- - IF sep = '' THEN - RETURN 'Fieldset ' || fieldset_id || ' has no column values defined'; - END IF; - -- - -- Add the WHERE clause. This differs according to whether it's a - -- single-row fieldset or a query-based fieldset. - -- - IF query IS NULL AND fs_pkey_value IS NULL THEN - RETURN 'Incomplete fieldset: neither a primary key nor a query available'; - ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN - fs_query := rtrim( query, ';' ); - statement := statement || ' WHERE ' || pkey_name || ' IN ( ' - || fs_query || ' );'; - ELSIF query IS NULL AND fs_pkey_value IS NOT NULL THEN - statement := statement || ' WHERE ' || pkey_name || ' = ' - || fs_pkey_value || ';'; - ELSE -- both are not null - RETURN 'Ambiguous fieldset: both a primary key and a query provided'; - END IF; - -- - -- Execute the update - -- - BEGIN - EXECUTE statement; - GET DIAGNOSTICS update_count = ROW_COUNT; - -- - IF UPDATE_COUNT > 0 THEN - status_code := 'APPLIED'; - msg := NULL; - ELSE - status_code := 'ERROR'; - msg := 'No eligible rows found for fieldset ' || fieldset_id; - END IF; - EXCEPTION WHEN OTHERS THEN - status_code := 'ERROR'; - msg := 'Unable to apply fieldset ' || fieldset_id - || ': ' || sqlerrm; - END; - -- - -- Update fieldset status - -- - UPDATE action.fieldset - SET status = status_code, - applied_time = now() - WHERE id = fieldset_id; - -- - RETURN msg; + -- Sanity checks + IF fieldset_id IS NULL THEN + RETURN 'Fieldset ID parameter is NULL'; + END IF; + IF table_name IS NULL THEN + RETURN 'Table name parameter is NULL'; + END IF; + IF pkey_name IS NULL THEN + RETURN 'Primary key name parameter is NULL'; + END IF; + + SELECT + status, + quote_literal( pkey_value ) + INTO + fs_status, + fs_pkey_value + FROM + action.fieldset + WHERE + id = fieldset_id; + + -- + -- Build the WHERE clause. This differs according to whether it's a + -- single-row fieldset or a query-based fieldset. + -- + IF query IS NULL AND fs_pkey_value IS NULL THEN + RETURN 'Incomplete fieldset: neither a primary key nor a query available'; + ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN + fs_query := rtrim( query, ';' ); + where_clause := 'WHERE ' || pkey_name || ' IN ( ' + || fs_query || ' )'; + ELSIF query IS NULL AND fs_pkey_value IS NOT NULL THEN + where_clause := 'WHERE ' || pkey_name || ' = '; + IF pkey_name = 'id' THEN + where_clause := where_clause || fs_pkey_value; + ELSIF pkey_name = 'code' THEN + where_clause := where_clause || quote_literal(fs_pkey_value); + ELSE + RETURN 'Only know how to handle "id" and "code" pkeys currently, received ' || pkey_name; + END IF; + ELSE -- both are not null + RETURN 'Ambiguous fieldset: both a primary key and a query provided'; + END IF; + + IF fs_status IS NULL THEN + RETURN 'No fieldset found for id = ' || fieldset_id; + ELSIF fs_status = 'APPLIED' THEN + RETURN 'Fieldset ' || fieldset_id || ' has already been applied'; + END IF; + + SELECT * INTO fs_obj FROM action.fieldset WHERE id = fieldset_id; + SELECT * INTO fs_group FROM action.fieldset_group WHERE id = fs_obj.fieldset_group; + + IF fs_group.can_rollback THEN + -- This is part of a non-rollback group. We need to record the current values for future rollback. + + INSERT INTO action.fieldset_group (can_rollback, name, creator, owning_lib, container, container_type) + VALUES (FALSE, 'ROLLBACK: '|| fs_group.name, fs_group.creator, fs_group.owning_lib, fs_group.container, fs_group.container_type); + + fsg_id := CURRVAL('action.fieldset_group_id_seq'); + + FOR rb_row IN EXECUTE 'SELECT * FROM ' || table_name || ' ' || where_clause LOOP + IF pkey_name = 'id' THEN + fs_pkey_value := rb_row.id; + ELSIF pkey_name = 'code' THEN + fs_pkey_value := rb_row.code; + ELSE + RETURN 'Only know how to handle "id" and "code" pkeys currently, received ' || pkey_name; + END IF; + INSERT INTO action.fieldset (fieldset_group,owner,owning_lib,status,classname,name,pkey_value) + VALUES (fsg_id, fs_obj.owner, fs_obj.owning_lib, 'PENDING', fs_obj.classname, fs_obj.name || ' ROLLBACK FOR ' || fs_pkey_value, fs_pkey_value); + + fs_id := CURRVAL('action.fieldset_id_seq'); + sep := ''; + FOR cv IN + SELECT DISTINCT col + FROM action.fieldset_col_val + WHERE fieldset = fieldset_id + LOOP + EXECUTE 'INSERT INTO action.fieldset_col_val (fieldset, col, val) ' || + 'SELECT '|| fs_id || ', '||quote_literal(cv.col)||', '||cv.col||' FROM '||table_name||' WHERE '||pkey_name||' = '||fs_pkey_value; + END LOOP; + END LOOP; + END IF; + + statement := 'UPDATE ' || table_name || ' SET'; + + sep := ''; + FOR cv IN + SELECT col, + val + FROM action.fieldset_col_val + WHERE fieldset = fieldset_id + LOOP + statement := statement || sep || ' ' || cv.col + || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' ); + sep := ','; + END LOOP; + + IF sep = '' THEN + RETURN 'Fieldset ' || fieldset_id || ' has no column values defined'; + END IF; + statement := statement || ' ' || where_clause; + + -- + -- Execute the update + -- + BEGIN + EXECUTE statement; + GET DIAGNOSTICS update_count = ROW_COUNT; + + IF update_count = 0 THEN + RAISE data_exception; + END IF; + + IF fsg_id IS NOT NULL THEN + UPDATE action.fieldset_group SET rollback_group = fsg_id WHERE id = fs_group.id; + END IF; + + IF fs_group.id IS NOT NULL THEN + UPDATE action.fieldset_group SET complete_time = now() WHERE id = fs_group.id; + END IF; + + UPDATE action.fieldset SET status = 'APPLIED', applied_time = now() WHERE id = fieldset_id; + + EXCEPTION WHEN data_exception THEN + msg := 'No eligible rows found for fieldset ' || fieldset_id; + UPDATE action.fieldset SET status = 'ERROR', applied_time = now() WHERE id = fieldset_id; + RETURN msg; + + END; + + RETURN msg; + +EXCEPTION WHEN OTHERS THEN + msg := 'Unable to apply fieldset ' || fieldset_id || ': ' || sqlerrm; + UPDATE action.fieldset SET status = 'ERROR', applied_time = now() WHERE id = fieldset_id; + RETURN msg; + END; $$ LANGUAGE plpgsql; @@ -1611,3 +1684,4 @@ UNION ALL WHERE aacirc.target_copy = ac_aacirc.id; COMMIT; + diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index e95d94caea..89097b7dcd 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -1679,7 +1679,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES ( 590, 'ADMIN_COPY_TAG_TYPES', oils_i18n_gettext( 590, 'Administer copy tag types', 'ppl', 'description' )), ( 591, 'ADMIN_COPY_TAG', oils_i18n_gettext( 591, - 'Administer copy tag', 'ppl', 'description' )) + 'Administer copy tag', 'ppl', 'description' )), + ( 592,'CONTAINER_BATCH_UPDATE', oils_i18n_gettext( 592, + 'Allow batch update via buckets', 'ppl', 'description' )) ; SELECT SETVAL('permission.perm_list_id_seq'::TEXT, 1000); @@ -5378,6 +5380,7 @@ INSERT INTO container.user_bucket_type (code,label) VALUES ('folks:circ.checkout INSERT INTO container.user_bucket_type (code,label) VALUES ('folks:hold.view', oils_i18n_gettext('folks:hold.view', 'View Holds', 'cubt', 'label')); 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'; ---------------------------------- -- MARC21 record structure data -- diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.patron_batch_update.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.patron_batch_update.sql new file mode 100644 index 0000000000..ad0bf9c6a2 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.patron_batch_update.sql @@ -0,0 +1,228 @@ +BEGIN; + +INSERT INTO permission.perm_list (id,code,description) VALUES (592,'CONTAINER_BATCH_UPDATE','Allow batch update via buckets'); + +INSERT INTO container.user_bucket_type (code,label) SELECT code,label FROM container.copy_bucket_type where code = 'staff_client'; + +CREATE TABLE action.fieldset_group ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + complete_time TIMESTAMPTZ, + container INT, -- Points to a container of some type ... + container_type TEXT, -- One of 'biblio_record_entry', 'user', 'call_number', 'copy' + can_rollback BOOL DEFAULT TRUE, + rollback_group INT REFERENCES action.fieldset_group (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + rollback_time TIMESTAMPTZ, + creator INT NOT NULL REFERENCES actor.usr (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + owning_lib INT NOT NULL REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED +); + +ALTER TABLE action.fieldset ADD COLUMN fieldset_group INT REFERENCES action.fieldset_group (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE action.fieldset ADD COLUMN error_msg TEXT; +ALTER TABLE container.biblio_record_entry_bucket ADD COLUMN owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE container.user_bucket ADD COLUMN owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE container.call_number_bucket ADD COLUMN owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE container.copy_bucket ADD COLUMN owning_lib INT REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +UPDATE query.stored_query SET id = id + 1000 WHERE id < 1000; +UPDATE query.from_relation SET id = id + 1000 WHERE id < 1000; +UPDATE query.expression SET id = id + 1000 WHERE id < 1000; + +SELECT SETVAL('query.stored_query_id_seq', 1, FALSE); +SELECT SETVAL('query.from_relation_id_seq', 1, FALSE); +SELECT SETVAL('query.expression_id_seq', 1, FALSE); + +INSERT INTO query.bind_variable (name,type,description,label) + SELECT 'bucket','number','ID of the bucket to pull items from','Bucket ID' + WHERE NOT EXISTS (SELECT 1 FROM query.bind_variable WHERE name = 'bucket'); + +-- Assumes completely empty 'query' schema +INSERT INTO query.stored_query (type, use_distinct) VALUES ('SELECT', TRUE); -- 1 + +INSERT INTO query.from_relation (type, table_name, class_name, table_alias) VALUES ('RELATION', 'container.user_bucket_item', 'cubi', 'cubi'); -- 1 +UPDATE query.stored_query SET from_clause = 1; + +INSERT INTO query.expr_xcol (table_alias, column_name) VALUES ('cubi', 'target_user'); -- 1 +INSERT INTO query.select_item (stored_query,seq_no,expression) VALUES (1,1,1); + +INSERT INTO query.expr_xcol (table_alias, column_name) VALUES ('cubi', 'bucket'); -- 2 +INSERT INTO query.expr_xbind (bind_variable) VALUES ('bucket'); -- 3 + +INSERT INTO query.expr_xop (left_operand, operator, right_operand) VALUES (2, '=', 3); -- 4 +UPDATE query.stored_query SET where_clause = 4; + +SELECT SETVAL('query.stored_query_id_seq', 1000, TRUE) FROM query.stored_query; +SELECT SETVAL('query.from_relation_id_seq', 1000, TRUE) FROM query.from_relation; +SELECT SETVAL('query.expression_id_seq', 10000, TRUE) FROM query.expression; + +CREATE OR REPLACE FUNCTION action.apply_fieldset( + fieldset_id IN INT, -- id from action.fieldset + table_name IN TEXT, -- table to be updated + pkey_name IN TEXT, -- name of primary key column in that table + query IN TEXT -- query constructed by qstore (for query-based + -- fieldsets only; otherwise null +) +RETURNS TEXT AS $$ +DECLARE + statement TEXT; + where_clause TEXT; + fs_status TEXT; + fs_pkey_value TEXT; + fs_query TEXT; + sep CHAR; + status_code TEXT; + msg TEXT; + fs_id INT; + fsg_id INT; + update_count INT; + cv RECORD; + fs_obj action.fieldset%ROWTYPE; + fs_group action.fieldset_group%ROWTYPE; + rb_row RECORD; +BEGIN + -- Sanity checks + IF fieldset_id IS NULL THEN + RETURN 'Fieldset ID parameter is NULL'; + END IF; + IF table_name IS NULL THEN + RETURN 'Table name parameter is NULL'; + END IF; + IF pkey_name IS NULL THEN + RETURN 'Primary key name parameter is NULL'; + END IF; + + SELECT + status, + quote_literal( pkey_value ) + INTO + fs_status, + fs_pkey_value + FROM + action.fieldset + WHERE + id = fieldset_id; + + -- + -- Build the WHERE clause. This differs according to whether it's a + -- single-row fieldset or a query-based fieldset. + -- + IF query IS NULL AND fs_pkey_value IS NULL THEN + RETURN 'Incomplete fieldset: neither a primary key nor a query available'; + ELSIF query IS NOT NULL AND fs_pkey_value IS NULL THEN + fs_query := rtrim( query, ';' ); + where_clause := 'WHERE ' || pkey_name || ' IN ( ' + || fs_query || ' )'; + ELSIF query IS NULL AND fs_pkey_value IS NOT NULL THEN + where_clause := 'WHERE ' || pkey_name || ' = '; + IF pkey_name = 'id' THEN + where_clause := where_clause || fs_pkey_value; + ELSIF pkey_name = 'code' THEN + where_clause := where_clause || quote_literal(fs_pkey_value); + ELSE + RETURN 'Only know how to handle "id" and "code" pkeys currently, received ' || pkey_name; + END IF; + ELSE -- both are not null + RETURN 'Ambiguous fieldset: both a primary key and a query provided'; + END IF; + + IF fs_status IS NULL THEN + RETURN 'No fieldset found for id = ' || fieldset_id; + ELSIF fs_status = 'APPLIED' THEN + RETURN 'Fieldset ' || fieldset_id || ' has already been applied'; + END IF; + + SELECT * INTO fs_obj FROM action.fieldset WHERE id = fieldset_id; + SELECT * INTO fs_group FROM action.fieldset_group WHERE id = fs_obj.fieldset_group; + + IF fs_group.can_rollback THEN + -- This is part of a non-rollback group. We need to record the current values for future rollback. + + INSERT INTO action.fieldset_group (can_rollback, name, creator, owning_lib, container, container_type) + VALUES (FALSE, 'ROLLBACK: '|| fs_group.name, fs_group.creator, fs_group.owning_lib, fs_group.container, fs_group.container_type); + + fsg_id := CURRVAL('action.fieldset_group_id_seq'); + + FOR rb_row IN EXECUTE 'SELECT * FROM ' || table_name || ' ' || where_clause LOOP + IF pkey_name = 'id' THEN + fs_pkey_value := rb_row.id; + ELSIF pkey_name = 'code' THEN + fs_pkey_value := rb_row.code; + ELSE + RETURN 'Only know how to handle "id" and "code" pkeys currently, received ' || pkey_name; + END IF; + INSERT INTO action.fieldset (fieldset_group,owner,owning_lib,status,classname,name,pkey_value) + VALUES (fsg_id, fs_obj.owner, fs_obj.owning_lib, 'PENDING', fs_obj.classname, fs_obj.name || ' ROLLBACK FOR ' || fs_pkey_value, fs_pkey_value); + + fs_id := CURRVAL('action.fieldset_id_seq'); + sep := ''; + FOR cv IN + SELECT DISTINCT col + FROM action.fieldset_col_val + WHERE fieldset = fieldset_id + LOOP + EXECUTE 'INSERT INTO action.fieldset_col_val (fieldset, col, val) ' || + 'SELECT '|| fs_id || ', '||quote_literal(cv.col)||', '||cv.col||' FROM '||table_name||' WHERE '||pkey_name||' = '||fs_pkey_value; + END LOOP; + END LOOP; + END IF; + + statement := 'UPDATE ' || table_name || ' SET'; + + sep := ''; + FOR cv IN + SELECT col, + val + FROM action.fieldset_col_val + WHERE fieldset = fieldset_id + LOOP + statement := statement || sep || ' ' || cv.col + || ' = ' || coalesce( quote_literal( cv.val ), 'NULL' ); + sep := ','; + END LOOP; + + IF sep = '' THEN + RETURN 'Fieldset ' || fieldset_id || ' has no column values defined'; + END IF; + statement := statement || ' ' || where_clause; + + -- + -- Execute the update + -- + BEGIN + EXECUTE statement; + GET DIAGNOSTICS update_count = ROW_COUNT; + + IF update_count = 0 THEN + RAISE data_exception; + END IF; + + IF fsg_id IS NOT NULL THEN + UPDATE action.fieldset_group SET rollback_group = fsg_id WHERE id = fs_group.id; + END IF; + + IF fs_group.id IS NOT NULL THEN + UPDATE action.fieldset_group SET complete_time = now() WHERE id = fs_group.id; + END IF; + + UPDATE action.fieldset SET status = 'APPLIED', applied_time = now() WHERE id = fieldset_id; + + EXCEPTION WHEN data_exception THEN + msg := 'No eligible rows found for fieldset ' || fieldset_id; + UPDATE action.fieldset SET status = 'ERROR', applied_time = now() WHERE id = fieldset_id; + RETURN msg; + + END; + + RETURN msg; + +EXCEPTION WHEN OTHERS THEN + msg := 'Unable to apply fieldset ' || fieldset_id || ': ' || sqlerrm; + UPDATE action.fieldset SET status = 'ERROR', applied_time = now() WHERE id = fieldset_id; + RETURN msg; + +END; +$$ LANGUAGE plpgsql; + +COMMIT; + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/index.tt2 new file mode 100644 index 0000000000..77d3ccaf9e --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/index.tt2 @@ -0,0 +1,79 @@ +[% + WRAPPER "staff/base.tt2"; + ctx.page_title = l("User Buckets"); + ctx.page_app = "egCatUserBuckets"; + ctx.page_ctrl = "UserBucketCtrl"; +%] + +[% BLOCK APP_JS %] + + + + + + + + +[% END %] + + + + +
+
+ + +
+
+ [% INCLUDE 'staff/circ/patron/bucket/t_bucket_info.tt2' %] +
+
+ + +
+
+ [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %] +
+
+ +
+
+
+ +[% END %] diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_create.tt2 new file mode 100644 index 0000000000..90687246ad --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_create.tt2 @@ -0,0 +1,35 @@ + + + +
+
+ + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_delete.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_delete.tt2 new file mode 100644 index 0000000000..0ca9887f9a --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_delete.tt2 @@ -0,0 +1,16 @@ + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_edit.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_edit.tt2 new file mode 100644 index 0000000000..852ba466d9 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_edit.tt2 @@ -0,0 +1,34 @@ + +
+
+ + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_info.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_info.tt2 new file mode 100644 index 0000000000..877fcf6aaf --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_info.tt2 @@ -0,0 +1,16 @@ + +
+ [% l('Bucket: {{bucket().name()}}') %] + + + + + / [% l('Created {{bucket().create_time() | date}}') %] + / {{bucket().description()}} +
+ +
+ [% l('No Bucket Selected') %] +
+ diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_selector.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_selector.tt2 new file mode 100644 index 0000000000..e9aeacc18e --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_selector.tt2 @@ -0,0 +1,27 @@ + + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_changesets.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_changesets.tt2 new file mode 100644 index 0000000000..dc98390a26 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_changesets.tt2 @@ -0,0 +1,41 @@ + +
+
+ + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_delete_all.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_delete_all.tt2 new file mode 100644 index 0000000000..5cb6d9248b --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_delete_all.tt2 @@ -0,0 +1,43 @@ + + + +
+
+ + + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_grid_menu.tt2 new file mode 100644 index 0000000000..a2e2bde533 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_grid_menu.tt2 @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_load_shared.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_load_shared.tt2 new file mode 100644 index 0000000000..9aab308bda --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_load_shared.tt2 @@ -0,0 +1,25 @@ + +
+
+ + + +
+
+ diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_pending.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_pending.tt2 new file mode 100644 index 0000000000..2df627e8fe --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_pending.tt2 @@ -0,0 +1,60 @@ +
+
+
+
+ [% l('Scan Card') %] + +
+
+
+
+
+
[% l('OR') %]
+
+ +
+
+
+
+
+
+ +
+ + + + [% INCLUDE 'staff/circ/patron/bucket/t_grid_menu.tt2' %] + + + + + + + + + + + {{item['card.barcode']}} + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_rollback.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_rollback.tt2 new file mode 100644 index 0000000000..792c5ee99f --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_rollback.tt2 @@ -0,0 +1,48 @@ + + + +
+
+ + + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_all.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_all.tt2 new file mode 100644 index 0000000000..d0d12dd682 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_all.tt2 @@ -0,0 +1,145 @@ + + + +
+
+ + + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_statcats.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_statcats.tt2 new file mode 100644 index 0000000000..0102b6fc74 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_update_statcats.tt2 @@ -0,0 +1,54 @@ + + + +
+
+ + + diff --git a/Open-ILS/src/templates/staff/circ/patron/bucket/t_view.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_view.tt2 new file mode 100644 index 0000000000..37cdaee866 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/bucket/t_view.tt2 @@ -0,0 +1,49 @@ + + + [% INCLUDE 'staff/circ/patron/bucket/t_grid_menu.tt2' %] + + + + + + + + + + + + + + + + + + + {{item['card.barcode']}} + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2 index de709954f9..5cb8a29e1f 100644 --- a/Open-ILS/src/templates/staff/circ/patron/index.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2 @@ -11,6 +11,7 @@ + @@ -61,6 +62,10 @@ angular.module('egCoreMod').run(['egStrings', function(s) { s.PATRON_PURGE_STAFF_BAD_BARCODE = "[% l('Could not retrieve a destination account with the barcode provided. Aborting the purge...') %]"; s.PATRON_PURGE_OVERRIDE_PROMPT = "[% l('The account has open transactions (circulations and/or unpaid bills). Purge anyway?') %]"; s.PATRON_EDIT_COLLISION = "[% l('Patron record was modified by another user while you were editing it. Your changes were not saved; please reapply them.') %]"; + s.OPT_IN_DIALOG_TITLE = "[% l('Verify Permission to Share Personal Information') %]"; + s.OPT_IN_DIALOG = "[% l('Does patron [_1], [_2] from [_3] ([_4]) consent to having their personal information shared with your library?', '{{family_name}}', '{{first_given_name}}', '{{org_name}}', '{{org_shortname}}') %]"; + s.BUCKET_ADD_SUCCESS = "[% l('Successfully added [_1] users to bucket [_2].', '{{count}}', '{{name}}') %]"; + s.BUCKET_ADD_FAIL = "[% l('Failed to add [_1] users to bucket [_2].', '{{count}}', '{{name}}') %]"; }]); diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 index 6981f10ddd..ffac971b1e 100644 --- a/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 @@ -4,15 +4,25 @@ idl-class="au" id-field="id" features="-sort,-display,-multisort" main-label="[% l('Patron Search Results') %]" + menu-label="[% l('Add To Bucket') %]" grid-controls="gridControls" items-provider="patronSearchGridProvider" persist-key="circ.patron.search" dateformat="{{$root.egDateAndTimeFormat}}"> + + + + + + diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2 index ba99321dd1..912c43cea1 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -128,6 +128,12 @@ [% l('Pending Patrons') %] +
  • + + + [% l('User Buckets') %] + +
  • diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js index cd8a36e7aa..a72a09801b 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -4,7 +4,7 @@ * Search, checkout, items out, holds, bills, edit, etc. */ -angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', +angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 'egUserBucketMod', 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast', 'egPatronSearchMod']) @@ -527,12 +527,12 @@ function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) { * Manages patron search */ .controller('PatronSearchCtrl', - ['$scope','$q','$routeParams','$timeout','$window','$location','egCore', - '$filter','egUser', 'patronSvc','egGridDataProvider','$document', - 'egPatronMerge','egProgressDialog','$controller', -function($scope, $q, $routeParams, $timeout, $window, $location, egCore, - $filter, egUser, patronSvc , egGridDataProvider , $document, - egPatronMerge , egProgressDialog, $controller) { + ['$scope','$q','$routeParams','$timeout','$window','$location','egCore','ngToast', + '$filter','egUser', 'patronSvc','egGridDataProvider','$document','bucketSvc', + 'egPatronMerge','egProgressDialog','$controller','$interpolate','$uibModal', +function($scope, $q, $routeParams, $timeout, $window, $location, egCore , ngToast, + $filter, egUser, patronSvc , egGridDataProvider , $document , bucketSvc, + egPatronMerge , egProgressDialog , $controller , $interpolate , $uibModal) { angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope})); $scope.initTab('search'); @@ -541,7 +541,65 @@ function($scope, $q, $routeParams, $timeout, $window, $location, egCore, activateItem : function(item) { $location.path('/circ/patron/' + item.id() + '/checkout'); }, - selectedItems : function() {return []} + selectedItems : function() { return [] } + } + + $scope.bucketSvc = bucketSvc; + $scope.bucketSvc.fetchUserBuckets(); + $scope.addToBucket = function(item, data, recs) { + if (recs.length == 0) return; + var added_count = 0; + var failed_count = 0; + var p = []; + angular.forEach(recs, + function(rec) { + var item = new egCore.idl.cubi(); + item.bucket(data.id()); + item.target_user(rec.id()); + p.push(egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.item.create', + egCore.auth.token(), 'user', item + ).then( + function(){ added_count++ }, + function(){ failed_count++ } + )); + } + ); + + $q.all(p).then( function () { + if (added_count) ngToast.create($interpolate(egCore.strings.BUCKET_ADD_SUCCESS)({ count: ''+added_count, name: data.name()} )); + if (failed_count) ngToast.warning($interpolate(egCore.strings.BUCKET_ADD_FAIL)({ count: ''+failed_count, name: data.name() } )); + }); + } + + var temp_scope = $scope; + $scope.openCreateBucketDialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_bucket_create', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = 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).then( + function(id) { + if (id) { + $scope.bucketSvc.fetchBucket(id).then(function (b) { + $scope.addToBucket( + null, + b, + $scope.gridControls.selectedItems() + ); + $scope.bucketSvc.fetchUserBuckets(true); + }); + } + } + ); + }); } $scope.$watch( @@ -553,6 +611,10 @@ function($scope, $q, $routeParams, $timeout, $window, $location, egCore, true ); + $scope.need_one_selected = function() { + var items = $scope.gridControls.selectedItems(); + return (items.length > 0) ? false : true; + } $scope.need_two_selected = function() { var items = $scope.gridControls.selectedItems(); return (items.length == 2) ? false : true; diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/bucket/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/bucket/app.js new file mode 100644 index 0000000000..0e8545ca94 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/bucket/app.js @@ -0,0 +1,789 @@ +/** + * 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('egCatUserBuckets', + ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'egUserBucketMod', 'ngToast']) + +.config(function($routeProvider, $locationProvider, $compileProvider) { + $locationProvider.html5Mode(true); + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export + + var resolver = {delay : function(egStartup) {return egStartup.go()}}; + + $routeProvider.when('/circ/patron/bucket/add/:id', { + templateUrl: './circ/patron/bucket/t_pending', + controller: 'PendingCtrl', + resolve : resolver + }); + + $routeProvider.when('/circ/patron/bucket/add', { + templateUrl: './circ/patron/bucket/t_pending', + controller: 'PendingCtrl', + resolve : resolver + }); + + $routeProvider.when('/circ/patron/bucket/view/:id', { + templateUrl: './circ/patron/bucket/t_view', + controller: 'ViewCtrl', + resolve : resolver + }); + + $routeProvider.when('/circ/patron/bucket/view', { + templateUrl: './circ/patron/bucket/t_view', + controller: 'ViewCtrl', + resolve : resolver + }); + + // default page / bucket view + $routeProvider.otherwise({redirectTo : '/circ/patron/bucket/view'}); +}) + +/** + * Top-level controller. + * Hosts functions needed by all controllers. + */ +.controller('UserBucketCtrl', + ['$scope','$location','$q','$timeout','$uibModal', + '$window','egCore','bucketSvc','ngToast', +function($scope, $location, $q, $timeout, $uibModal, + $window, egCore, bucketSvc , ngToast) { + + $scope.bucketSvc = bucketSvc; + $scope.bucket = function() { return bucketSvc.currentBucket } + + // tabs: add, 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( + '/circ/patron/bucket/' + + $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 + ).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.resetPendingList(); + } + + $scope.openCreateBucketDialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_bucket_create', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = 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).then( + function(id) { + if (!id) return; + bucketSvc.viewList = []; + bucketSvc.allBuckets = []; // reset + bucketSvc.currentBucket = null; + $location.path( + '/circ/patron/bucket/' + $scope.tab + '/' + id); + } + ); + }); + } + + $scope.openEditBucketDialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_bucket_edit', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.focusMe = true; + $scope.args = { + name : bucketSvc.currentBucket.name(), + desc : bucketSvc.currentBucket.description(), + pub : bucketSvc.currentBucket.pub() == 't' + }; + $scope.ok = function(args) { + if (!args) return; + $scope.actionPending = true; + args.pub = args.pub ? 't' : 'f'; + // close the dialog after edit has completed + bucketSvc.editBucket(args).then( + function() { $uibModalInstance.close() }); + } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }] + }) + } + + // opens the delete confirmation and deletes the current + // bucket if the user confirms. + $scope.openDeleteBucketDialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_bucket_delete', + 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('/circ/patron/bucket/view'); + }); + }); + } + + // retrieves the requested bucket by ID + $scope.openSharedBucketDialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_load_shared', + 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','ngToast','$q', +function($scope, $routeParams, bucketSvc , egGridDataProvider, egCore , ngToast , $q) { + $scope.setTab('add'); + + var query; + $scope.gridControls = { + setQuery : function(q) { + if (bucketSvc.pendingList.length) + return {id : bucketSvc.pendingList}; + else + return null; + } + } + + $scope.$watch('barcodesFromFile', function(newVal, oldVal) { + if (newVal && newVal != oldVal) { + var promises = []; + // $scope.resetPendingList(); // ??? Add instead of replace + angular.forEach(newVal.split(/\n/), function(line) { + if (!line) return; + // scrub any trailing spaces or commas from the barcode + line = line.replace(/(.*?)($|\s.*|,.*)/,'$1'); + promises.push(egCore.pcrud.search( + 'ac', + {barcode : line}, + {} + ).then(null, null, function(card) { + bucketSvc.pendingList.push(card.usr()); + })); + }); + + $q.all(promises).then(function () { + $scope.gridControls.setQuery({id : bucketSvc.pendingList}); + }); + } + }); + + $scope.search = function() { + bucketSvc.barcodeRecords = []; + + egCore.pcrud.search( + 'ac', + {barcode : bucketSvc.barcodeString}, + {} + ).then(null, null, function(card) { + bucketSvc.pendingList.push(card.usr()); + $scope.gridControls.setQuery({id : bucketSvc.pendingList}); + }); + bucketSvc.barcodeString = ''; + } + + $scope.resetPendingList = function() { + bucketSvc.pendingList = []; + $scope.gridControls.setQuery({}); + } + + $scope.$parent.resetPendingList = $scope.resetPendingList; + + 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','egPerm','ngToast','$filter', +function($scope, $q , $routeParams , $timeout , $window , $uibModal , bucketSvc , egCore , egUser , + egConfirmDialog , egPerm , ngToast , $filter) { + + $scope.setTab('view'); + $scope.bucketId = $routeParams.id; + + var query; + $scope.gridControls = { + setQuery : function(q) { + if (q) query = q; + return query; + } + }; + + $scope.modifyStatcats = function() { + bucketSvc.bucketNeedsRefresh = true; + + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_update_statcats', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.running = false; + $scope.complete = false; + $scope.states = []; + + $scope.modal = $uibModalInstance; + $scope.ok = function(args) { $uibModalInstance.close() } + $scope.cancel = function () { $uibModalInstance.dismiss() } + + $scope.current_bucket = bucketSvc.currentBucket; + + egCore.net.request( + 'open-ils.circ', + 'open-ils.circ.stat_cat.actor.retrieve.all', + egCore.auth.token(), egCore.auth.user().ws_ou() + ).then(function(cats) { + cats = cats.sort(function(a, b) { + return a.name() < b.name() ? -1 : 1}); + angular.forEach(cats, function(cat) { + cat.new_value = ''; + cat.allow_freetext(parseInt(cat.allow_freetext())); // just to be sure + cat.entries( + cat.entries().sort(function(a,b) { + return a.value() < b.value() ? -1 : 1 + }) + ); + }); + $scope.stat_cats = cats; + }); + + // This handels the progress magic instead of a normal close handler + $scope.$on('modal.closing', function(event, reason, closed) { + if (!closed) return; // dismissed + if ($scope.complete) return; // already done + + $scope.running = true; + + var changes = {remove:[], apply:{}}; + angular.forEach($scope.stat_cats, function (sc) { + if (sc.delete_me) { + changes.remove.push(sc.id()); + } else if (sc.new_value) { + changes.apply[sc.id()] = sc.new_value; + } + }); + + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.user.batch_statcat_apply', + egCore.auth.token(), bucketSvc.currentBucket.id(), changes + ).then( + function () { + $scope.complete = true; + $scope.modal.close(); + drawBucket(); + }, + function (err) { console.log('User edit error: ' + err); }, + function (p) { + if (p.error) { + ngToast.warning(p.error); + } + if (p.stage == 'COMPLETE') return; + + p.label = egCore.strings[p.stage]; + if (!p.max) { + p.max = 1; + p.count = 1; + } + $scope.states[p.ord] = p; + } + ); + + return event.preventDefault(); + }); + }] + }); + } + + + 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.no_update_perms = true; + $scope.noUpdatePerms = function () { return $scope.no_update_perms; } + + egPerm.hasPermHere(['UPDATE_USER']).then( + function (hash) { + if (Object.keys(hash).length == 0) return; + + var one_false = false; + angular.forEach(hash, function(has) { + if (!has) one_false = true; + }); + + if (!one_false) $scope.no_update_perms = false; + } + ); + + function annotate_groups(grps) { + angular.forEach(grps, function (g) { + if (!g.hasOwnProperty('cannot_use')) { + if (g.usergroup() == 'f') { + g.cannot_use = true; + } else if (g.application_perm) { + egPerm.hasPermHere(['EVERYTHING',g.application_perm]).then( + function (hash) { + if (Object.keys(hash).length == 0) { + g.cannot_use = true; + return; + } + + var one_false = false; + angular.forEach(hash, function(has) { + if (has) g.cannot_use = false; + }); + } + ); + } else { + g.cannot_use = false; + } + } + }); + } + + $scope.viewChangesets = function() { + bucketSvc.bucketNeedsRefresh = true; + + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_changesets', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.running = false; + $scope.complete = false; + $scope.states = []; + + $scope.focusMe = true; + $scope.modal = $uibModalInstance; + $scope.ok = function() { $uibModalInstance.close() } + $scope.cancel = function () { $uibModalInstance.dismiss() } + + $scope.current_bucket = bucketSvc.currentBucket; + $scope.fieldset_groups = []; + + $scope.deleteChangeset = function (grp) { + egCore.pcrud.remove(grp).then( + function () { + if (grp.rollback_group()) { + egCore.pcrud + .retrieve('afsg',grp.rollback_group()) + .then(function(g) { + egCore.pcrud.remove(g) + .then( function () { refresh_groups() } ); + }); + } + } + ); + return event.preventDefault(); + } + + function refresh_groups () { + $scope.fieldset_groups = []; + egCore.pcrud.search('afsg',{ + rollback_group : { '>' : 0 }, + container : bucketSvc.currentBucket.id(), + container_type : 'user' + } ).then( null,null,function(g) { + $scope.fieldset_groups.push(g); + }); + } + refresh_groups(); + + }] + }); + } + + $scope.applyRollback = function() { + bucketSvc.bucketNeedsRefresh = true; + + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_rollback', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.running = false; + $scope.complete = false; + $scope.states = []; + $scope.revert_me = null; + + $scope.focusMe = true; + $scope.modal = $uibModalInstance; + $scope.ok = function(args) { $uibModalInstance.close() } + $scope.cancel = function () { $uibModalInstance.dismiss() } + + $scope.current_bucket = bucketSvc.currentBucket; + $scope.revertable_fieldset_groups = []; + + egCore.pcrud.search('afsg',{ + rollback_group : { '>' : 0}, + rollback_time : null, + container : bucketSvc.currentBucket.id(), + container_type : 'user' + } ).then( null,null,function(g) { + $scope.revertable_fieldset_groups.push(g); + }); + + // This handels the progress magic instead of a normal close handler + $scope.$on('modal.closing', function(event, reason, closed) { + if (!$scope.revert_me) return; + if (!closed) return; // dismissed + if ($scope.complete) return; // already done + + $scope.running = true; + + var last_stage = ''; + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.user.apply_rollback', + egCore.auth.token(), bucketSvc.currentBucket.id(), $scope.revert_me.id() + ).then( + function () { + $scope.complete = true; + $scope.modal.close(); + drawBucket(); + }, + function (err) { console.log('User edit error: ' + err); }, + function (p) { + last_stage = p.stage; + if (p.error) { + ngToast.warning(p.error); + } + if (p.stage == 'COMPLETE') return; + + p.label = egCore.strings[p.stage]; + if (!p.max) { + p.max = 1; + p.count = 1; + } + $scope.states[p.ord] = p; + } + ).then(function() { + if (last_stage != 'COMPLETE') + ngToast.warning(egCore.strings.BATCH_FAILED); + }); + + return event.preventDefault(); + }); + }] + }); + } + + $scope.updateAllUsers = function() { + bucketSvc.bucketNeedsRefresh = true; + + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_update_all', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.running = false; + $scope.complete = false; + $scope.states = []; + $scope.home_ou_name = ''; + $scope.args = {home_ou:null}; + $scope.focusMe = true; + $scope.modal = $uibModalInstance; + $scope.ok = function(args) { $uibModalInstance.close() } + $scope.cancel = function () { $uibModalInstance.dismiss() } + + $scope.disable_home_org = function(org_id) { + if (!org_id) return; + var org = egCore.org.get(org_id); + return ( + org && + org.ou_type() && + org.ou_type().can_have_users() == 'f' + ); + } + + $scope.pgt_depth = function(grp) { + var d = 0; + while (grp = egCore.env.pgt.map[grp.parent()]) d++; + return d; + } + + if (egCore.env.cnal) { + $scope.net_access_levels = egCore.env.cnal.list; + } else { + egCore.pcrud.retrieveAll('cnal', {}, {atomic : true}) + .then(function(types) { + egCore.env.absorbList(types, 'cnal') + $scope.net_access_levels = egCore.env.cnal.list; + }); + } + + if (egCore.env.pgt) { + $scope.profiles = egCore.env.pgt.list; + annotate_groups($scope.profiles); + } else { + egCore.pcrud.search('pgt', {parent : null}, + {flesh : -1, flesh_fields : {pgt : ['children']}} + ).then( + function(tree) { + egCore.env.absorbTree(tree, 'pgt') + $scope.profiles = egCore.env.pgt.list; + annotate_groups($scope.profiles); + } + ); + } + + $scope.unset_field = function (event,field) { + $scope.args[field] = null; + return event.preventDefault(); + } + + // This handels the progress magic instead of a normal close handler + $scope.$on('modal.closing', function(event, reason, closed) { + if (!$scope.args || !$scope.args.name) return; + if (!closed) return; // dismissed + if ($scope.complete) return; // already done + + $scope.running = true; + + // XXX fix up $scope.args values here + if ($scope.args.home_ou) { + $scope.args.home_ou = $scope.args.home_ou.id(); + } + if ($scope.args.net_access_level) { + $scope.args.net_access_level = $scope.args.net_access_level.id(); + } + if ($scope.args.profile) { + $scope.args.profile = $scope.args.profile.id(); + } + if ($scope.args.expire_date) { + $scope.args.expire_date = $scope.args.expire_date.toJSON().substr(0,10); + } + + for (var key in $scope.args) { + if (!$scope.args[key] && $scope.args[key] !== 0) { + delete $scope.args[key]; + } + } + + var last_stage = ''; + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.user.batch_edit', + egCore.auth.token(), bucketSvc.currentBucket.id(), $scope.args.name, $scope.args + ).then( + function () { + $scope.complete = true; + $scope.modal.close(); + drawBucket(); + }, + function (err) { console.log('User edit error: ' + err); }, + function (p) { + last_stage = p.stage; + if (p.error) { + ngToast.warning(p.error); + } + if (p.stage == 'COMPLETE') return; + + p.label = egCore.strings[p.stage]; + if (!p.max) { + p.max = 1; + p.count = 1; + } + $scope.states[p.ord] = p; + } + ).then(function() { + if (last_stage != 'COMPLETE') + ngToast.warning(egCore.strings.BATCH_FAILED); + }); + + return event.preventDefault(); + }); + }] + }); + } + + $scope.no_delete_perms = true; + $scope.noDeletePerms = function () { return $scope.no_delete_perms; } + + egPerm.hasPermHere(['UPDATE_USER','DELETE_USER']).then( + function (hash) { + if (Object.keys(hash).length == 0) return; + + var one_false = false; + angular.forEach(hash, function(has) { + if (!has) one_false = true; + }); + + if (!one_false) $scope.no_delete_perms = false; + } + ); + + $scope.deleteAllUsers = function() { + bucketSvc.bucketNeedsRefresh = true; + + $uibModal.open({ + templateUrl: './circ/patron/bucket/t_delete_all', + controller: + ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.running = false; + $scope.complete = false; + $scope.states = []; + $scope.args = {}; + $scope.focusMe = true; + $scope.modal = $uibModalInstance; + $scope.ok = function(args) { $uibModalInstance.close() } + $scope.cancel = function () { $uibModalInstance.dismiss() } + + // This handels the progress magic instead of a normal close handler + $scope.$on('modal.closing', function(event, reason, closed) { + if (!$scope.args || !$scope.args.name) return; + if (!closed) return; // dismissed + if ($scope.complete) return; // already done + + $scope.running = true; + + var last_stage = ''; + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.container.user.batch_delete', + egCore.auth.token(), bucketSvc.currentBucket.id(), $scope.args.name, { deleted : 't' } + ).then( + function () { + $scope.complete = true; + $scope.modal.close(); + drawBucket(); + }, + function (err) { console.log('User deletion error: ' + err); }, + function (p) { + last_stage = p.stage; + if (p.error) { + ngToast.warning(p.error); + } + if (p.stage == 'COMPLETE') return; + + p.label = egCore.strings[p.stage]; + if (!p.max) { + p.max = 1; + p.count = 1; + } + $scope.states[p.ord] = p; + } + ).then(function() { + if (last_stage != 'COMPLETE') + ngToast.warning(egCore.strings.BATCH_FAILED); + }); + + return event.preventDefault(); + }); + }] + }); + + } + + $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.spawnUserEdit = function (users) { + angular.forEach($scope.gridControls.selectedItems(), function (i) { + var url = egCore.env.basePath + 'circ/patron/' + i.id + '/edit'; + $timeout(function() { $window.open(url, '_blank') }); + }) + } + + // fetch the bucket; on error show the not-allowed message + if ($scope.bucketId) + drawBucket()['catch'](function() { $scope.forbidden = true }); +}]) diff --git a/Open-ILS/web/js/ui/default/staff/services/user-bucket.js b/Open-ILS/web/js/ui/default/staff/services/user-bucket.js new file mode 100644 index 0000000000..487c385843 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/user-bucket.js @@ -0,0 +1,156 @@ +/** + * User Buckets + * + */ + +angular.module('egUserBucketMod', ['egCoreMod']) +.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 + + // per-page list collections + pendingList : [], + viewList : [], + + // fetches all staff/user 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', 'staff_client' + ).then(function(buckets) { self.allBuckets = buckets }); + }, + + createBucket : function(name, desc) { + var deferred = $q.defer(); + var bucket = new egCore.idl.cub(); + bucket.owner(egCore.auth.user().id()); + bucket.name(name); + bucket.description(desc || ''); + bucket.btype('staff_client'); + + 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); + 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; + } + 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; +}])