LP#1689608: Batch user editing
authorMike Rylander <mrylander@gmail.com>
Thu, 2 Feb 2017 20:29:46 +0000 (15:29 -0500)
committerGalen Charlton <gmc@equinoxinitiative.org>
Mon, 28 Aug 2017 14:35:28 +0000 (10:35 -0400)
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 <mrylander@gmail.com>
Signed-off-by: Scott Thomas <scott.thomas@sparkpa.org>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Conflicts:
Open-ILS/src/templates/staff/circ/patron/index.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/app.js

28 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
Open-ILS/src/sql/Pg/008.schema.query.sql
Open-ILS/src/sql/Pg/070.schema.container.sql
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.schema.patron_batch_update.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_delete.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_info.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_changesets.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_delete_all.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_grid_menu.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_load_shared.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_rollback.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_update_all.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_update_statcats.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/bucket/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/index.tt2
Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/bucket/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/user-bucket.js [new file with mode: 0644]

index f2abaa7..e0d7584 100644 (file)
@@ -2418,7 +2418,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
        </class>
-       <class id="ccbi" controller="open-ils.cstore" oils_obj:fieldmapper="container::copy_bucket_item" oils_persist:tablename="container.copy_bucket_item" reporter:label="Copy Bucket Item">
+       <class id="ccbi" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::copy_bucket_item" oils_persist:tablename="container.copy_bucket_item" reporter:label="Copy Bucket Item">
                <fields oils_persist:primary="id" oils_persist:sequence="container.copy_bucket_item_id_seq">
                        <field name="bucket" />
                        <field name="id" reporter:datatype="id" />
@@ -2432,6 +2432,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="bucket" reltype="has_a" key="id" map="" class="ccb"/>
             <link field="notes" reltype="has_many" map="" key="item" class="ccbin"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_COPY_BUCKET">
+                    <context link="bucket" owning_lib="owning_lib"/>
+                </retrieve>
+            </actions>
+        </permacrud>
        </class>
        <class id="ccbin" controller="open-ils.cstore" oils_obj:fieldmapper="container::copy_bucket_item_note" oils_persist:tablename="container.copy_bucket_item_note" reporter:label="Copy Bucket Item Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.copy_bucket_item_note_id_seq">
@@ -4730,7 +4737,7 @@ SELECT  usr,
                </permacrud>
        </class>
 
-       <class id="ccnbi" controller="open-ils.cstore" oils_obj:fieldmapper="container::call_number_bucket_item" oils_persist:tablename="container.call_number_bucket_item" reporter:label="Call Number Bucket Item">
+       <class id="ccnbi" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::call_number_bucket_item" oils_persist:tablename="container.call_number_bucket_item" reporter:label="Call Number Bucket Item">
                <fields oils_persist:primary="id" oils_persist:sequence="container.call_number_bucket_item_id_seq">
                        <field name="bucket" reporter:datatype="link"/>
                        <field name="id" reporter:datatype="id" />
@@ -4744,6 +4751,13 @@ SELECT  usr,
                        <link field="bucket" reltype="has_a" key="id" map="" class="ccnb"/>
             <link field="notes" reltype="has_many" map="" key="item" class="ccnbin"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_CALLNUMBER_BUCKET">
+                    <context link="bucket" owning_lib="owning_lib"/>
+                </retrieve>
+            </actions>
+        </permacrud>
        </class>
        <class id="ccnbin" controller="open-ils.cstore" oils_obj:fieldmapper="container::call_number_bucket_item_note" oils_persist:tablename="container.call_number_bucket_item_note" reporter:label="Call Number Bucket Item Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.call_number_bucket_item_note_id_seq">
@@ -4755,7 +4769,7 @@ SELECT  usr,
                        <link field="item" reltype="has_a" key="id" map="" class="ccnbi"/>
                </links>
        </class>
-       <class id="cbreb" controller="open-ils.cstore" oils_obj:fieldmapper="container::biblio_record_entry_bucket" oils_persist:tablename="container.biblio_record_entry_bucket" reporter:label="Bibliographic Record Entry Bucket">
+       <class id="cbreb" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::biblio_record_entry_bucket" oils_persist:tablename="container.biblio_record_entry_bucket" reporter:label="Bibliographic Record Entry Bucket">
                <fields oils_persist:primary="id" oils_persist:sequence="container.biblio_record_entry_bucket_id_seq">
                        <field name="items" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field name="btype" reporter:datatype="text"/>
@@ -4765,11 +4779,21 @@ SELECT  usr,
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
+                       <field name="owning_lib" reporter:datatype="org_unit" />
                </fields>
                <links>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="owner" reltype="has_a" key="id" map="" class="au"/>
                        <link field="items" reltype="has_many" key="bucket" map="" class="cbrebi"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_BIB_BUCKET ADMIN_BIB_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <retrieve permission="CREATE_BIB_BUCKET ADMIN_BIB_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <update permission="CREATE_BIB_BUCKET ADMIN_BIB_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <delete permission="CREATE_BIB_BUCKET ADMIN_BIB_BUCKET" context="owning_lib" owning_user="owner"/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="cbrebn" controller="open-ils.cstore" oils_obj:fieldmapper="container::biblio_record_entry_bucket_note" oils_persist:tablename="container.biblio_record_entry_bucket_note" reporter:label="Bibliographic Record Entry Bucket Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.biblio_record_entry_bucket_note_id_seq">
@@ -6298,7 +6322,7 @@ SELECT  usr,
                        </actions>
                </permacrud>
        </class>
-       <class id="ccnb" controller="open-ils.cstore" oils_obj:fieldmapper="container::call_number_bucket" oils_persist:tablename="container.call_number_bucket" reporter:label="Call Number Bucket">
+       <class id="ccnb" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::call_number_bucket" oils_persist:tablename="container.call_number_bucket" reporter:label="Call Number Bucket">
                <fields oils_persist:primary="id" oils_persist:sequence="container.call_number_bucket_id_seq">
                        <field name="items" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field name="btype" reporter:datatype="text"/>
@@ -6308,11 +6332,21 @@ SELECT  usr,
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
+                       <field name="owning_lib" reporter:datatype="org_unit" />
                </fields>
                <links>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="owner" reltype="has_a" key="id" map="" class="au"/>
                        <link field="items" reltype="has_many" key="bucket" map="" class="ccnbi"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_CALLNUMBER_BUCKET ADMIN_CALLNUMBER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <retrieve permission="CREATE_CALLNUMBER_BUCKET ADMIN_CALLNUMBER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <update permission="CREATE_CALLNUMBER_BUCKET ADMIN_CALLNUMBER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <delete permission="CREATE_CALLNUMBER_BUCKET ADMIN_CALLNUMBER_BUCKET" context="owning_lib" owning_user="owner"/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="ccnbn" controller="open-ils.cstore" oils_obj:fieldmapper="container::call_number_bucket_note" oils_persist:tablename="container.call_number_bucket_note" reporter:label="Call Number Bucket Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.call_number_bucket_note_id_seq">
@@ -6423,7 +6457,7 @@ SELECT  usr,
                        <link field="field" reltype="has_a" key="id" map="" class="cmf"/>
                </links>
        </class>
-       <class id="cub" controller="open-ils.cstore" oils_obj:fieldmapper="container::user_bucket" oils_persist:tablename="container.user_bucket" reporter:label="User Bucket">
+       <class id="cub" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::user_bucket" oils_persist:tablename="container.user_bucket" reporter:label="User Bucket">
                <fields oils_persist:primary="id" oils_persist:sequence="container.user_bucket_id_seq">
                        <field name="items" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field name="btype" reporter:datatype="text"/>
@@ -6433,11 +6467,21 @@ SELECT  usr,
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
+                       <field name="owning_lib" reporter:datatype="org_unit" />
                </fields>
                <links>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="owner" reltype="has_a" key="id" map="" class="au"/>
                        <link field="items" reltype="has_many" key="bucket" map="" class="cubi"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_USER_BUCKET ADMIN_USER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <retrieve permission="CREATE_USER_BUCKET ADMIN_USER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <update permission="CREATE_USER_BUCKET ADMIN_USER_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <delete permission="CREATE_USER_BUCKET ADMIN_USER_BUCKET" context="owning_lib" owning_user="owner"/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="cubn" controller="open-ils.cstore" oils_obj:fieldmapper="container::user_bucket_note" oils_persist:tablename="container.user_bucket_note" reporter:label="User Bucket Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.user_bucket_note_id_seq">
@@ -6683,7 +6727,7 @@ SELECT  usr,
                        <link field="stat_cat_entry" reltype="has_a" key="id" map="" class="actsce"/>
                </links>
        </class>
-       <class id="cubi" controller="open-ils.cstore" oils_obj:fieldmapper="container::user_bucket_item" oils_persist:tablename="container.user_bucket_item" reporter:label="User Bucket Item">
+       <class id="cubi" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::user_bucket_item" oils_persist:tablename="container.user_bucket_item" reporter:label="User Bucket Item">
                <fields oils_persist:primary="id" oils_persist:sequence="container.user_bucket_item_id_seq">
                        <field name="bucket" reporter:datatype="link"/>
                        <field name="id" reporter:datatype="id" />
@@ -6697,6 +6741,13 @@ SELECT  usr,
                        <link field="bucket" reltype="has_a" key="id" map="" class="cub"/>
             <link field="notes" reltype="has_many" map="" key="item" class="cubin"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_USER_BUCKET">
+                    <context link="bucket" owning_lib="owning_lib"/>
+                </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">
@@ -7213,7 +7264,7 @@ SELECT  usr,
             </actions>
         </permacrud>
        </class>
-       <class id="ccb" controller="open-ils.cstore" oils_obj:fieldmapper="container::copy_bucket" oils_persist:tablename="container.copy_bucket" reporter:label="Copy Bucket">
+       <class id="ccb" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::copy_bucket" oils_persist:tablename="container.copy_bucket" reporter:label="Copy Bucket">
                <fields oils_persist:primary="id" oils_persist:sequence="container.copy_bucket_id_seq">
                        <field name="items" oils_persist:virtual="true" />
                        <field name="btype" reporter:datatype="text"/>
@@ -7223,11 +7274,21 @@ SELECT  usr,
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
+                       <field name="owning_lib" reporter:datatype="org_unit" />
                </fields>
                <links>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="owner" reltype="has_a" key="id" map="" class="au"/>
                        <link field="items" reltype="has_many" key="bucket" map="" class="ccbi"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_COPY_BUCKET ADMIN_COPY_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <retrieve permission="CREATE_COPY_BUCKET ADMIN_COPY_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <update permission="CREATE_COPY_BUCKET ADMIN_COPY_BUCKET" context="owning_lib" owning_user="owner"/>
+                               <delete permission="CREATE_COPY_BUCKET ADMIN_COPY_BUCKET" context="owning_lib" owning_user="owner"/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="ccbn" controller="open-ils.cstore" oils_obj:fieldmapper="container::copy_bucket_note" oils_persist:tablename="container.copy_bucket_note" reporter:label="Copy Bucket Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.copy_bucket_note_id_seq">
@@ -7394,7 +7455,7 @@ SELECT  usr,
                        <link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
                </links>
        </class>
-       <class id="cbrebi" controller="open-ils.cstore" oils_obj:fieldmapper="container::biblio_record_entry_bucket_item" oils_persist:tablename="container.biblio_record_entry_bucket_item" reporter:label="Biblio Record Entry Bucket Item">
+       <class id="cbrebi" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::biblio_record_entry_bucket_item" oils_persist:tablename="container.biblio_record_entry_bucket_item" reporter:label="Biblio Record Entry Bucket Item">
                <fields oils_persist:primary="id" oils_persist:sequence="container.biblio_record_entry_bucket_item_id_seq">
                        <field name="bucket" reporter:datatype="link"/>
                        <field name="id" reporter:datatype="id" />
@@ -7408,6 +7469,13 @@ SELECT  usr,
                        <link field="bucket" reltype="has_a" key="id" map="" class="cbreb"/>
             <link field="notes" reltype="has_many" map="" key="item" class="cbrebin"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_COPY_BUCKET">
+                    <context link="bucket" owning_lib="owning_lib"/>
+                </retrieve>
+            </actions>
+        </permacrud>
        </class>
        <class id="cbrebin" controller="open-ils.cstore" oils_obj:fieldmapper="container::biblio_record_entry_bucket_item_note" oils_persist:tablename="container.biblio_record_entry_bucket_item_note" reporter:label="Biblio Record Entry Bucket Item Note">
                <fields oils_persist:primary="id" oils_persist:sequence="container.biblio_record_entry_bucket_item_note_id_seq">
@@ -9761,7 +9829,35 @@ SELECT  usr,
         </fields>
     </class>
 
-       <class id="afs" controller="open-ils.cstore" oils_obj:fieldmapper="action::fieldset" oils_persist:tablename="action.fieldset" reporter:label="Fieldset">
+       <class id="afsg" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::fieldset_group" oils_persist:tablename="action.fieldset_group" reporter:label="Fieldset Group">
+               <fields oils_persist:primary="id" oils_persist:sequence="action.fieldset_group_id_seq">
+                       <field reporter:label="Fieldset Group ID" name="id" reporter:datatype="id"/>
+                       <field reporter:label="Fieldset Group Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Creation Time" name="create_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Complete Time" name="complete_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Container ID" name="container" reporter:datatype="int" oils_obj:required="true"/> <!-- not an fkey because could be on multiple tables -->
+                       <field reporter:label="Container Type" name="container_type" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Rollback Group" name="rollback_group" reporter:datatype="link"/>
+                       <field reporter:label="Rollback Time" name="rollback_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Creator" name="creator" reporter:datatype="link"/>
+                       <field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit"/>
+               </fields>
+               <links>
+                       <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="rollback_group" reltype="has_a" key="id" map="" class="afsg"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_FIELDSET_GROUP ADMIN_FIELDSET_GROUP" context_field="owning_lib" owning_user="creator"/>
+                               <retrieve permission="RETRIEVE_FIELDSET_GROUP ADMIN_FIELDSET_GROUP" context_field="owning_lib" owning_user="creator"/>
+                               <update permission="UPDATE_FIELDSET_GROUP ADMIN_FIELDSET_GROUP" context_field="owning_lib" owning_user="creator"/>
+                               <delete permission="DELETE_FIELDSET_GROUP ADMIN_FIELDSET_GROUP" context_field="owning_lib" owning_user="creator"/>
+                       </actions>
+               </permacrud>
+       </class>
+
+       <class id="afs" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::fieldset" oils_persist:tablename="action.fieldset" reporter:label="Fieldset">
                <fields oils_persist:primary="id" oils_persist:sequence="action.fieldset_id_seq">
                        <field reporter:label="Fieldset ID" name="id" reporter:datatype="id"/>
                        <field reporter:label="Owner" name="owner" reporter:datatype="link"/>
@@ -9774,15 +9870,26 @@ SELECT  usr,
                        <field reporter:label="Fieldset Name" name="name" reporter:datatype="text"/>
                        <field reporter:label="Stored Query" name="stored_query" reporter:datatype="link"/>
                        <field reporter:label="Primary Key Value" name="pkey_value" reporter:datatype="text"/>
+                       <field reporter:label="Fieldset Group" name="fieldset_group" reporter:datatype="link"/>
+                       <field reporter:label="Error Message" name="error_msg" reporter:datatype="text"/>
                </fields>
                <links>
                        <link field="owner" reltype="has_a" key="id" map="" class="au"/>
                        <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
                        <link field="stored_query" reltype="has_a" key="id" map="" class="qsq"/>
+                       <link field="fieldset_group" reltype="has_a" key="id" map="" class="afsg"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="CREATE_FIELDSET ADMIN_FIELDSET" context_field="owning_lib" owning_user="owner"/>
+                               <retrieve permission="RETRIEVE_FIELDSET ADMIN_FIELDSET" context_field="owning_lib" owning_user="owner"/>
+                               <update permission="UPDATE_FIELDSET ADMIN_FIELDSET" context_field="owning_lib" owning_user="owner"/>
+                               <delete permission="DELETE_FIELDSET ADMIN_FIELDSET" context_field="owning_lib" owning_user="owner"/>
+                       </actions>
+               </permacrud>
        </class>
 
-       <class id="afscv" controller="open-ils.cstore" oils_obj:fieldmapper="action::fieldset_col_val" oils_persist:tablename="action.fieldset_col_val" reporter:label="Fieldset Column Value">
+       <class id="afscv" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::fieldset_col_val" oils_persist:tablename="action.fieldset_col_val" reporter:label="Fieldset Column Value">
                <fields oils_persist:primary="id" oils_persist:sequence="action.fieldset_col_val_id_seq">
                        <field reporter:label="Column Value ID" name="id" reporter:datatype="id"/>
                        <field reporter:label="Fieldset ID" name="fieldset" reporter:datatype="link"/>
@@ -9792,6 +9899,22 @@ SELECT  usr,
                <links>
                        <link field="fieldset" reltype="has_a" key="id" map="" class="afs"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1"> <!-- NOTE: foreign context does not support owning_user today -->
+                       <actions>
+                               <create permission="CREATE_FIELDSET_ENTRY ADMIN_FIELDSET_ENTRY">
+                    <context link="fieldset" field="owning_lib"/>
+                </create>
+                               <retrieve permission="RETRIEVE_FIELDSET_ENTRY ADMIN_FIELDSET_ENTRY">
+                    <context link="fieldset" field="owning_lib"/>
+                </retrieve>
+                               <update permission="UPDATE_FIELDSET_ENTRY ADMIN_FIELDSET_ENTRY">
+                    <context link="fieldset" field="owning_lib"/>
+                </update>
+                               <delete permission="DELETE_FIELDSET_ENTRY ADMIN_FIELDSET_ENTRY">
+                    <context link="fieldset" field="owning_lib"/>
+                </delete>
+                       </actions>
+               </permacrud>
        </class>
 
     <class id="aufhl" controller="open-ils.cstore" oils_obj:fieldmapper="action::unfulfilled_hold_loops" oils_persist:tablename="action.unfulfilled_hold_loops" oils_persist:readonly="true">
index dada935..badb134 100644 (file)
@@ -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;
index 1dc97b1..8b8fd89 100644 (file)
@@ -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;
index 0e21c5f..32dfc6a 100644 (file)
@@ -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)
 );
index 18604b8..806814d 100644 (file)
@@ -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;
+
index e95d94c..89097b7 100644 (file)
@@ -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 (file)
index 0000000..ad0bf9c
--- /dev/null
@@ -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 (file)
index 0000000..77d3cca
--- /dev/null
@@ -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 %]
+<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/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user-bucket.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/bucket/app.js"></script>
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+
+  s.CONTAINER_BATCH_UPDATE_PERM_CHECK = "[% l('Container batch update permission check') %]";
+  s.CONTAINER_PERM_CHECK = "[% l('Container permission check') %]";
+  s.ITEM_PERM_CHECK = "[% l('Item permission check') %]";
+  s.STAT_CAT_REMOVE = "[% l('Statistical category removal') %]";
+  s.STAT_CAT_APPLY = "[% l('Statistical category application') %]";
+  s.FIELDSET_GROUP_CREATE = "[% l('Fieldset group creation') %]";
+  s.FIELDSET_CREATE = "[% l('Fieldset creation') %]";
+  s.FIELDSET_EDITS_CREATE = "[% l('Fieldset change creation') %]";
+  s.CONSTRUCT_QUERY = "[% l('Query construction') %]";
+  s.APPLY_EDITS = "[% l('Applying edits') %]";
+  s.COMPLETE = "[% l('Complete') %]";
+  s.BATCH_FAILED = "[% l('Batch update failed!') %]";
+
+}]);
+</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 == 'add'}">
+    <a href="./circ/patron/bucket/add/{{bucketSvc.currentBucket.id()}}">
+        [% l('Pending Users') %]
+        <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'view'}">
+    <a href="./circ/patron/bucket/view/{{bucketSvc.currentBucket.id()}}">
+        [% l('Bucket View') %]
+        <span ng-cloak>({{bucketSvc.currentBucket.items().length}})</span>
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <!-- bucket info header -->
+    <div class="row">
+      <div class="col-md-6">
+        [% INCLUDE 'staff/circ/patron/bucket/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/circ/patron/bucket/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_bucket_create.tt2
new file mode 100644 (file)
index 0000000..9068724
--- /dev/null
@@ -0,0 +1,35 @@
+<!-- edit bucket 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('Create Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"/> 
+          [% l('Staff Sharable?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Create Bucket') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..0ca9887
--- /dev/null
@@ -0,0 +1,16 @@
+<div class="modal-dialog">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Confirm Bucket Delete') %]</h4>
+    </div>
+    <div class="modal-body">
+      <p>[% l('Delete bucket {{bucket().name()}}?') %]</p>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-primary" ng-click="ok()">[% l('Delete Bucket') %]</button>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</div> <!-- modal-dialog -->
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 (file)
index 0000000..852ba46
--- /dev/null
@@ -0,0 +1,34 @@
+<!-- edit bucket dialog -->
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Edit Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"> 
+          [% l('Staff Sharable?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..877fcf6
--- /dev/null
@@ -0,0 +1,16 @@
+
+<div ng-show="bucket()">
+  <strong>[% l('Bucket: {{bucket().name()}}') %]</strong> 
+  <span>
+    <ng-pluralize count="bucketSvc.currentBucket.items().length"
+      when="{'one': '[% l("1 item") %]', 'other': '[% l("{} items") %]'}">
+    </ng-pluralize>
+  </span> 
+  <span> / [% l('Created {{bucket().create_time() | date}}') %]</span>
+  <span ng-show="bucket().description()"> / {{bucket().description()}}</span>
+</div>
+
+<div ng-show="!bucket()">
+  <strong>[% l('No Bucket Selected') %]</strong>
+</div>
+
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 (file)
index 0000000..e9aeacc
--- /dev/null
@@ -0,0 +1,27 @@
+<div class="btn-group text-left" uib-dropdown>
+  <button type="button" class="btn btn-default" uib-dropdown-toggle>
+    [% l('Buckets') %]<span class="caret"></span>
+  </button>
+  <ul uib-dropdown-menu>
+    <li>
+      <a href='' ng-click="openCreateBucketDialog()">[% l('New Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openEditBucketDialog()">[% l('Edit Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openDeleteBucketDialog()">[% l('Delete Bucket') %]</a>
+    </li>
+    <li>
+      <a href='' ng-click="openSharedBucketDialog()">[% l('Load Shared Bucket') %]</a>
+    </li>
+    <li role="presentation" class="divider"></li>
+
+    <!-- list all of this user's buckets -->
+    <li ng-repeat="bkt in bucketSvc.allBuckets" 
+      ng-class="{disabled : bkt.id() == bucket().id()}">
+      <a href='' ng-click="loadBucket(bkt.id())">{{bkt.name()}}</a>
+    </li>
+  </ul>
+</div>
+
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 (file)
index 0000000..dc98390
--- /dev/null
@@ -0,0 +1,41 @@
+<!-- manage batch changes dialog -->
+<form class="form-validated" novalidate ng-submit="ok()" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('View batch changes') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-4">
+          <strong>[% l('Name') %]</strong>
+        </div>
+        <div class="col-md-3">
+          <strong>[% l('Completed') %]</strong>
+        </div>
+        <div class="col-md-3">
+          <strong>[% l('Rolled back') %]</strong>
+        </div>
+      </div>
+      <div class="row" ng-repeat="g in fieldset_groups track by $index">
+        <div class="col-md-4">
+          {{g.name()}}
+        </div>
+        <div class="col-md-3">
+          {{g.complete_time() | date}}
+        </div>
+        <div class="col-md-3">
+          {{g.rollback_time() | date}}
+        </div>
+        <div class="col-md-2">
+          <button class="btn btn-primary"
+            ng-click="deleteChangeset(g)">[% l('Delete') %]</button>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..5cb6d92
--- /dev/null
@@ -0,0 +1,43 @@
+<!-- edit bucket dialog -->
+<style>
+progress {
+  text-align: center;
+  height: 25px;
+  width: 500px;
+  margin-bottom: 10px;
+}
+
+progress:before {
+  content: attr(label);
+  position: relative;
+  top: 5px;
+}
+</style>
+
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Delete all users') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-delete-name">[% l('Name for delete set') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-delete-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+    <div class="modal-body" ng-show='running'>
+      <div ng-repeat="progress in states">
+        <progress label="{{progress.label}}" max="{{progress.max}}" value="{{progress.count}}">{{progress.label}}</progress>
+      </div>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..a2e2bde
--- /dev/null
@@ -0,0 +1,20 @@
+
+<!-- global grid menu displayed on every Bucket page -->
+<eg-grid-menu-item label="[% l('New Bucket') %]" 
+  handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Edit Bucket') %]" 
+  handler="openEditBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Delete Bucket') %]" 
+  handler="openDeleteBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Shared Bucket') %]" 
+  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/circ/patron/bucket/t_load_shared.tt2 b/Open-ILS/src/templates/staff/circ/patron/bucket/t_load_shared.tt2
new file mode 100644 (file)
index 0000000..9aab308
--- /dev/null
@@ -0,0 +1,25 @@
+<!-- load bucket by id ("shared") -->
+<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('Load Shared Bucket Bucket by ID') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="load-bucket-id">[% l('Bucket ID') %]</label>
+        <!-- NOTE: type='number' / required -->
+        <input type="number" class="form-control" focus-me='focusMe' required
+          id="load-bucket-id" ng-model="args.id" placeholder="[% l('Bucket ID...') %]"/>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Load Bucket') %]"/>
+      <button class="btn btn-warning" 
+          ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
+
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 (file)
index 0000000..2df627e
--- /dev/null
@@ -0,0 +1,60 @@
+<form ng-submit="search()">
+<div class="row">
+  <div class="col-md-6">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Scan Card') %]</span>
+        <input type="text" class="form-control" focus-me="focusMe"
+        ng-model="bucketSvc.barcodeString" placeholder="[% l('Barcode...') %]">
+      </div>
+  </div>
+  <div class="col-md-6">
+    <div class="btn-pad" style="padding:4px;">
+      <div class="flex-row">
+        <div class="strong-text">[% l('OR') %]</div>
+        <div class="btn-pad">
+          <input type="file" eg-file-reader
+            container="barcodesFromFile" value="[% l('Upload from File') %]">
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+</form>
+
+<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"
+  menu-label="[% l('Buckets') %]"
+  persist-key="user.bucket.pending">
+
+  [% INCLUDE 'staff/circ/patron/bucket/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Bucket') %]" 
+    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('Barcode') %]"
+    path="card.barcode" visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/circ/patron/{{item.id}}/edit">
+      {{item['card.barcode']}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('First Name') %]" path="first_given_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Middle Name') %]" path="second_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('Home Library') %]"    path="home_ou.name" visible></eg-grid-field>
+
+</eg-grid>
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 (file)
index 0000000..792c5ee
--- /dev/null
@@ -0,0 +1,48 @@
+<!-- edit bucket dialog -->
+<style>
+progress {
+  text-align: center;
+  height: 25px;
+  width: 500px;
+  margin-bottom: 10px;
+}
+
+progress:before {
+  content: attr(label);
+  position: relative;
+  top: 5px;
+}
+</style>
+
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Roll back batch edit') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <select
+            class="form-control"
+            ng-model="revert_me"
+            required
+            ng-options="g.name() for g in revertable_fieldset_groups track by g.id()"
+        >
+          <option value="">[% l('--- Select edit to roll back ---') %]</option>
+        </select>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Roll Back Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+    <div class="modal-body" ng-show='running'>
+      <div ng-repeat="progress in states">
+        <progress label="{{progress.label}}" max="{{progress.max}}" value="{{progress.count}}">{{progress.label}}</progress>
+      </div>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..d0d12dd
--- /dev/null
@@ -0,0 +1,145 @@
+<!-- edit bucket dialog -->
+<style>
+progress {
+  text-align: center;
+  height: 25px;
+  width: 500px;
+  margin-bottom: 10px;
+}
+
+progress:before {
+  content: attr(label);
+  position: relative;
+  top: 5px;
+}
+</style>
+
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Update all users') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-name">[% l('Name for edit set') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <hr/>
+      <div>
+        <div class="row">
+          <div class="col-md-3">
+            <eg-org-selector
+                selected="args.home_ou"
+                nodefault
+                label="[% l('Home Library') %]"
+                disable-test="disable_home_org">
+            </eg-org-selector>
+            <br/>
+            <button class="btn btn-default" ng-click="unset_field($event,'home_ou')">[% l('Unset') %]</button>
+          </div>
+          <div class="col-md-3">
+            <div class="btn-group patron-search-selector" uib-dropdown>
+              <button type="button" class="btn btn-default" uib-dropdown-toggle>
+                <span style="padding-right: 5px;">{{args.profile.name() || "[% l('Main Profile') %]"}}</span>
+                <span class="caret"></span>
+              </button>
+              <ul uib-dropdown-menu>
+                <li ng-repeat="grp in profiles">
+                  <a href a-disabled="grp.cannot_use"
+                    style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
+                    ng-click="args.profile = grp">{{grp.name()}}</a>
+                </li>
+              </ul>
+              <br/>
+              <button class="btn btn-default" ng-click="unset_field($event,'profile')">[% l('Unset') %]</button>
+            </div>
+          </div>
+          <div class="col-md-3">
+            <div class="btn-group patron-search-selector" uib-dropdown>
+              <button type="button" class="btn btn-default" uib-dropdown-toggle>
+                <span style="padding-right: 5px;">{{args.net_access_level.name() || "[% l('Internet Access Level') %]"}}</span>
+                <span class="caret"></span>
+              </button>
+              <ul uib-dropdown-menu>
+                <li ng-repeat="l in net_access_levels">
+                  <a href
+                    ng-click="args.net_access_level = l">{{l.name()}}</a>
+                </li>
+              </ul>
+              <br/>
+              <button class="btn btn-default" ng-click="unset_field($event,'net_access_level')">[% l('Unset') %]</button>
+            </div>
+          </div>
+        </div>
+        <br/>
+        <br/>
+      </div>
+      <div class="form-group">
+        <div class="row">
+          <div class="col-md-6">
+            <label for="edit-active">[% l('Barred flag') %]</label>
+          </div>
+          <div class="col-md-6">
+            <select class="form-control" id="edit-active" ng-model="args.barred">
+              <option value="">[% l('Unchanged') %]</option>
+              <option value="t">[% l('True') %]</option>
+              <option value="f">[% l('False') %]</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="form-group">
+        <div class="row">
+          <div class="col-md-6">
+            <label for="edit-active">[% l('Active flag') %]</label>
+          </div>
+          <div class="col-md-6">
+            <select class="form-control" id="edit-active" ng-model="args.active">
+              <option value="">[% l('Unchanged') %]</option>
+              <option value="t">[% l('True') %]</option>
+              <option value="f">[% l('False') %]</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="form-group">
+        <div class="row">
+          <div class="col-md-6">
+            <label for="edit-juvenile">[% l('Juvenile flag') %]</label>
+          </div>
+          <div class="col-md-6">
+            <select class="form-control" id="edit-juvenile" ng-model="args.juvenile">
+              <option value="">[% l('Unchanged') %]</option>
+              <option value="t">[% l('True') %]</option>
+              <option value="f">[% l('False') %]</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="form-group">
+        <div class="row">
+          <div class="col-md-6">
+            <label for="edit-expire_date">[% l('Privilege Expiration Date') %]</label>
+          </div>
+          <div class="col-md-6">
+            <input type="date" class="form-control" id="edit-expire_date" ng-model="args.expire_date"/>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+    <div class="modal-body" ng-show='running'>
+      <div ng-repeat="progress in states">
+        <progress label="{{progress.label}}" max="{{progress.max}}" value="{{progress.count}}">{{progress.label}}</progress>
+      </div>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..0102b6f
--- /dev/null
@@ -0,0 +1,54 @@
+<!-- edit statcats dialog -->
+<style>
+progress {
+  text-align: center;
+  height: 25px;
+  width: 500px;
+  margin-bottom: 10px;
+}
+
+progress:before {
+  content: attr(label);
+  position: relative;
+  top: 5px;
+}
+</style>
+
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Update statistical categories') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div>
+        <div class="row" ng-repeat="sc in stat_cats track by $index">
+          <div class="col-md-4">
+            {{sc.name()}}
+          </div>
+          <div class="col-md-4">
+            <select class="form-control" ng-model="sc.new_value">
+              <option value="">[% l('Unchanged') %]</option>
+              <option ng-repeat="e in sc.entries()" value="{{e.value()}}">{{e.value()}}</option>
+            </select>
+            <input type="text" ng-disabled="!sc.allow_freetext()" class="form-control" ng-model="sc.new_value"/>
+          </div>
+          <div class="col-md-4">
+            <strong>[% l('Remove:') %]</strong> <input type="checkbox" ng-model="sc.delete_me"/>
+          </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+    <div class="modal-body" ng-show='running'>
+      <div ng-repeat="progress in states">
+        <progress label="{{progress.label}}" max="{{progress.max}}" value="{{progress.count}}">{{progress.label}}</progress>
+      </div>
+    </div>
+  </div> <!-- modal-content -->
+</form>
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 (file)
index 0000000..37cdaee
--- /dev/null
@@ -0,0 +1,49 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="allowAll,-display"
+  id-field="id"
+  idl-class="au"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="user.bucket.view">
+
+  [% INCLUDE 'staff/circ/patron/bucket/t_grid_menu.tt2' %]
+
+  <eg-grid-menu-item disabled="noDeletePerms" label="[% l('Delete all users') %]"
+    handler="deleteAllUsers" standalone="true"></eg-grid-menu-item>
+
+  <eg-grid-menu-item disabled="noUpdatePerms" label="[% l('Batch edit all users') %]"
+    handler="updateAllUsers" standalone="true"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('View batch edits') %]"
+    handler="viewChangesets" standalone="true"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Roll back batch edit') %]"
+    handler="applyRollback" standalone="true"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Batch modify statistical categories') %]"
+    handler="modifyStatcats" standalone="true"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Individually Edit Selected Users') %]" 
+    handler="spawnUserEdit"></eg-grid-action>
+  <eg-grid-action label="[% l('Remove Selected Users from Bucket') %]" 
+    handler="detachUsers"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></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}}/edit">
+      {{item['card.barcode']}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('First Name') %]" path="first_given_name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Middle Name') %]" path="second_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('Home Library') %]"    path="home_ou.name" visible></eg-grid-field>
+  <eg-grid-field path="mailing_address.*" hidden></eg-grid-field>
+  <eg-grid-field path="billing_address.*" hidden></eg-grid-field>
+
+</eg-grid>
index de70995..5cb8a29 100644 (file)
@@ -11,6 +11,7 @@
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/date.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user-bucket.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/patrons.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
@@ -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}}') %]";
 }]);
 </script>
 
index 6981f10..ffac971 100644 (file)
@@ -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}}">
 
   <eg-grid-menu-item handler="merge_patrons"
-    disabled="need_two_selected"
+    disabled="need_two_selected" standalone="true"
     label="[% l('Merge Patrons') %]"></eg-grid-menu-item>
 
+  <eg-grid-menu-item label="[% l('New Bucket') %]"
+    handler="openCreateBucketDialog"></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="addToBucket" disabled="need_one_selected"></eg-grid-menu-item>
+
   <eg-grid-field label="[% ('ID') %]" path='id' visible></eg-grid-field>
   <eg-grid-field label="[% ('Card') %]" path='card.barcode' visible></eg-grid-field>
   <eg-grid-field label="[% ('Profile') %]" path='profile.name' visible></eg-grid-field>
index ba99321..912c43c 100644 (file)
               [% l('Pending Patrons') %]
             </a>
           </li>
+          <li>
+            <a href="./circ/patron/bucket/view" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('User Buckets') %]
+            </a>
+          </li>
           <li class="divider"></li>
           <li>
             <a href="./circ/patron/credentials" target="_self">
index cd8a36e..a72a098 100644 (file)
@@ -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 (file)
index 0000000..0e8545c
--- /dev/null
@@ -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 (file)
index 0000000..487c385
--- /dev/null
@@ -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;
+}])