[open-ils-commits] [GIT] Evergreen ILS branch master updated. fc9af0e90530c034f4ae0545e286cc44964fce6d

Evergreen Git git at git.evergreen-ils.org
Mon Jul 24 11:37:43 EDT 2017


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  fc9af0e90530c034f4ae0545e286cc44964fce6d (commit)
       via  4e23a6a10199eeb289c9b4dbe476683ea455f4d7 (commit)
       via  3709094d3c2ec6ce38d022c6c6054737ba5e09a4 (commit)
       via  75513a28b49f65c6b40e0345e56aa0ef7eab243f (commit)
       via  e2f42eca432eb1d3317eab11d2d5869b2105931b (commit)
       via  2f4111d29af1f1372f7ddf5ea21644955dce897c (commit)
       via  8f6e436a78efc5aa11164dadb5c868f34b0f2d19 (commit)
       via  0b08d83e6f05ff863ef85c63cb86f5b7f39b5c23 (commit)
       via  12894c4f76f1ee6e8f2a58ddabe27c39e281a8be (commit)
       via  1bf0a255cac45c82d909bacb214d2649bb63edda (commit)
       via  e02f34dbe844d1cd2193486593f832af3193ecd7 (commit)
       via  0da6edee161f256f3d167d489bc7e9922e030548 (commit)
      from  5ed2320b3f78a4f537f00a384ddb6873e48b6dfa (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit fc9af0e90530c034f4ae0545e286cc44964fce6d
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Mon Jul 24 11:32:10 2017 -0400

    LP#1673857: stamp schema update
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 9edf3b1..468d4ec 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -90,7 +90,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1046', :eg_version); -- phasefx/berick/gmcharlt
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1047', :eg_version); -- gmcharlt/stompro
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql b/Open-ILS/src/sql/Pg/upgrade/1047.schema.copy_tags.sql
similarity index 97%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql
rename to Open-ILS/src/sql/Pg/upgrade/1047.schema.copy_tags.sql
index 07cabb9..896ac7f 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1047.schema.copy_tags.sql
@@ -1,5 +1,7 @@
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('1047', :eg_version); -- gmcharlt/stompro
+
 CREATE TABLE config.copy_tag_type (
     code            TEXT NOT NULL PRIMARY KEY,
     label           TEXT NOT NULL,

commit 4e23a6a10199eeb289c9b4dbe476683ea455f4d7
Author: Josh Stompro <stomproj at larl.org>
Date:   Mon Jul 17 11:35:24 2017 -0500

    LP#1673857: Disable browser autocomplete for tag entry
    
    Adds an autocomplete="off" to the input field to stop browsers
    from trying to fill in values.  When the browser fills in values
    for tags it can prevent the typeahead feature from being used
    easily.
    
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
index 00d6db7..bc009b6 100644
--- a/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
+++ b/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
@@ -22,7 +22,7 @@
             <label for="tagLabel">[% l('Tag') %]</label>
             <input name="tabLabel" type="text" ng-model="selectedLabel" placeholder="[% l('Enter tag label...') %]"
                 uib-typeahead="tag for tag in getTags($viewValue)" typeahead-editable="false"
-                class="form-control"></input>
+                class="form-control" autocomplete="off"></input>
           </div>
           <button type="button" class="btn btn-sm btn-default" ng-click="addTag()">[% l('Add Tag') %]</button>
         </div>
diff --git a/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2 b/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
index 01932ad..c2878f8 100644
--- a/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
+++ b/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
@@ -22,7 +22,7 @@
             <label for="tagLabel">[% l('Tag') %]</label>
             <input name="tabLabel" type="text" ng-model="selectedLabel" placeholder="[% l('Enter tag label...') %]"
                 uib-typeahead="tag for tag in getTags($viewValue)"
-                class="form-control"></input>
+                class="form-control" autocomplete="off"></input>
           </div>
           <button type="button" class="btn btn-sm btn-default" ng-click="addTag()">[% l('Add Tag') %]</button>
         </div>

commit 3709094d3c2ec6ce38d022c6c6054737ba5e09a4
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu May 11 12:46:38 2017 -0400

    LP#1673857: release notes
    
    Overall test plan
    -----------------
    [1] In Server Admnistration -> Copy Tag Types, test creating
        and modifying copy tag types.
    [2] In Server Admnistration -> Copy Tags, test creating
        and modifying copy tags.
    [3] In the volume/copy editor, test use of the 'Copy Tags'
        button to link or unlink tags from copies.
    [4] In the volume/copy editor, test use of the 'Copy Tags'
        button to create and link new tags on the fly.
    [5] In the copy buckets interface, test use of the 'Apply Tags'
        grid action to link tags to copies.
    [6] In the catalog, test the copy_tag(type, search) and
        copy_tag(*, search) filters. Verify that tag visiblity
        (as controlled by the "public" flag on a copy tag) is
        respected.
    [7] In the catalog, test the display of copy tags in
        the copy table on the record summary page.
    [8] Test the opac.search.enable_bookplate_search library setting
        and verify that it causes a 'Digital Bookplates' search option
        to be added.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Cataloging/Copy_tags.adoc b/docs/RELEASE_NOTES_NEXT/Cataloging/Copy_tags.adoc
new file mode 100644
index 0000000..6be2ab2
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Cataloging/Copy_tags.adoc
@@ -0,0 +1,51 @@
+Copy Tags and Digital Bookplates
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Copy tags will allow staff to apply custom, pre-defined labels or tags
+to copies.  Copy tags are searchable in both the staff client and public
+catalog.  This feature was designed to be used for Digital Bookplates to
+attach donation or memorial information to copies, but may be used for
+broader purposes to tag items.
+
+Each copy tag can either be publicly-visible or visible only to staff.
+Copy tags also have types that can be used for restricting catalog
+searches on copy tags to particular types.
+
+Copy tags are displayed in the copy table in the record summary page in
+the public catalog, and a new library setting can be used to add
+a "Digital Bookplate" search field.  Copy tags can also be used
+as a search filter, e.g.,
+
+  * `copy_tag(bookplate, jane smith)`: search for records that have a
+    copy tag of type `bookplate` whose value contains `jane smith`.
+  * `copy_tag(*, jane smith)`: search for records that have a
+    copy tag of any type whose value contains `jane smith`.
+
+All staff-side interfaces related to copy tags exist only in the web
+staff client.  There are two new administration interfaces for managing
+copy tags and copy tag types. The copy editor now has a `Copy Tags`
+button for applying copy tags to copies; that interface can also be
+used to create new copy tags on the fly. Furthermore, the copy buckets
+interface now has an `Apply Tags` action for assigning tags to groups
+of copies.
+
+Permissions
++++++++++++
+
+Two new permission are included:
+
+  * `ADMIN_COPY_TAG_TYPES`: required to create a new tag type under
+     Server Administration->Copy Tag Types
+  * `ADMIN_COPY_TAG`: required to create a new tag under
+    Local Administration->Copy Tags
+
+The existing permission `UPDATE_COPY` controls whether or not a user
+can link copies to tags.
+
+Library Settings
+++++++++++++++++
+A new library setting, "Enable Digital Bookplate Search", controls
+whether to display a "Digital Bookplate" field in the search index
+drop-downs in the catalog. A "Digital Bookplate" search will include
+all records that have a copy that matches the tag specified by the user.
+It should be noted that this library settings does not affect the
+display of copy tags on the catalog record summary page.

commit 75513a28b49f65c6b40e0345e56aa0ef7eab243f
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu May 11 13:03:16 2017 -0400

    LP#1673857: some test cases
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/t/21-QueryParser.t b/Open-ILS/src/perlmods/t/21-QueryParser.t
index 81bf051..67a0635 100644
--- a/Open-ILS/src/perlmods/t/21-QueryParser.t
+++ b/Open-ILS/src/perlmods/t/21-QueryParser.t
@@ -40,7 +40,7 @@ is (scalar(@{$QParser->facet_fields()->{'author'}}), 1, "Removed facet field");
 $QParser->remove_facet_class('author');
 is ($QParser->facet_class_count, 1, "Removed facet class");
 
-is ($QParser->filter_count, 28, "Correct number of filters");
+is ($QParser->filter_count, 29, "Correct number of filters");
 is (scalar(@{$QParser->filter_normalizers('skip_check')}), 0, 'No filter normalizers by default');
 $QParser->add_filter_normalizer('skip_check', \&test_filter_norm);
 is (scalar(@{$QParser->filter_normalizers('skip_check')}), 1, 'Added filter normalizer');
@@ -284,6 +284,7 @@ sub init_qp {
     $QParser->add_search_filter( 'skip_check' );
     $QParser->add_search_filter( 'superpage' );
     $QParser->add_search_filter( 'estimation_strategy' );
+    $QParser->add_search_filter( 'copy_tag' );
     $QParser->add_search_modifier( 'available' );
     $QParser->add_search_modifier( 'staff' );
 
diff --git a/Open-ILS/src/sql/Pg/t/copy_tags.pg b/Open-ILS/src/sql/Pg/t/copy_tags.pg
new file mode 100644
index 0000000..249d7a0
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/t/copy_tags.pg
@@ -0,0 +1,55 @@
+BEGIN;
+
+-- Plan the tests.
+SELECT plan(4);
+
+INSERT INTO asset.copy_tag(tag_type, label, owner ) VALUES ('bookplate', 'lp1673857_dummy_copy_tag', 1);
+SELECT is(
+  value,
+  'lp1673857_dummy_copy_tag',
+  'Copy tag label can be used to set copy tag value')
+FROM asset.copy_tag
+WHERE tag_type = 'bookplate'
+AND   label = 'lp1673857_dummy_copy_tag'
+AND   owner = 1;
+
+UPDATE asset.copy_tag
+SET    value = 'jane q. smith'
+WHERE tag_type = 'bookplate'
+AND   label = 'lp1673857_dummy_copy_tag'
+AND   owner = 1;
+
+SELECT is(
+  COUNT(*),
+  1::BIGINT,
+  'Copy tag value FTS works #1'
+)
+FROM asset.copy_tag
+WHERE tag_type = 'bookplate'
+AND   label = 'lp1673857_dummy_copy_tag'
+AND   value @@ to_tsquery('jane')
+AND   owner = 1;
+
+SELECT is(
+  COUNT(*),
+  1::BIGINT,
+  'Copy tag value FTS works #2'
+)
+FROM asset.copy_tag
+WHERE tag_type = 'bookplate'
+AND   label = 'lp1673857_dummy_copy_tag'
+AND   value @@ to_tsquery('jane & smith')
+AND   owner = 1;
+
+SELECT is(
+  COUNT(*),
+  0::BIGINT,
+  'Copy tag value FTS works #3'
+)
+FROM asset.copy_tag
+WHERE tag_type = 'bookplate'
+AND   label = 'lp1673857_dummy_copy_tag'
+AND   value @@ to_tsquery('jane & wesson')
+AND   owner = 1;
+
+ROLLBACK;

commit e2f42eca432eb1d3317eab11d2d5869b2105931b
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Mar 31 18:31:52 2017 -0400

    LP#1673857: interface for applying tags from copy buckets
    
    The copy buckets interface now includes an 'Apply Tags'
    action that can be used to map tags to a set of selected
    copies. Note that interface cannot be used to remove
    tag mappings; the volume/copy editor is needed to do that.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
new file mode 100644
index 0000000..00d6db7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
@@ -0,0 +1,39 @@
+<form ng-submit="ok(note)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">×</button>
+      <h4 class="modal-title">[% l('Apply Copy Tags') %]</h4>
+    </div>
+    <div class="modal-body">
+      <ul>
+        <li ng-repeat="map in tag_map" ng-show="!map.isdeleted()">
+            <span class="copy_tag_label">{{map.tag().label()}}</span>
+            <button type="button" ng-click="map.isdeleted(1)" class="btn btn-xs btn-warning">[% ('Remove') %]</button>
+        </li>
+      </ul>
+      <div class="row">
+        <div class="col-md-12 form-inline">
+          <div class="form-group">
+            <label for="tagType">[% l('Tag Type') %]</label>
+            <select class="form-control" name="tagType" ng-model="tag_type"
+                    ng-options="t.code() as t.label() for t in tag_types"></select>
+          </div>
+          <div class="form-group">
+            <label for="tagLabel">[% l('Tag') %]</label>
+            <input name="tabLabel" type="text" ng-model="selectedLabel" placeholder="[% l('Enter tag label...') %]"
+                uib-typeahead="tag for tag in getTags($viewValue)" typeahead-editable="false"
+                class="form-control"></input>
+          </div>
+          <button type="button" class="btn btn-sm btn-default" ng-click="addTag()">[% l('Add Tag') %]</button>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <div class="col-md-12 pull-right">
+          <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+          <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+        </div>
+      </div>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2
index 655e785..c2daf27 100644
--- a/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2
+++ b/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2
@@ -20,6 +20,8 @@
     handler="transferCopies"></eg-grid-action>
   <eg-grid-action label="[% l('Delete Selected Copies from Catalog') %]" 
     handler="deleteCopiesFromCatalog"></eg-grid-action>
+  <eg-grid-action label="[% l('Apply Tags') %]" 
+    handler="applyTags"></eg-grid-action>
 
   <eg-grid-field path="id" required hidden></eg-grid-field>
   <eg-grid-field path="call_number.record.id" required hidden></eg-grid-field>
diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js
index fc78a77..7eea044 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js
@@ -656,6 +656,84 @@ function($scope,  $q , $routeParams , $timeout , $window , $uibModal , bucketSvc
         }
     }
 
+    $scope.applyTags = function(copies) {
+        return $uibModal.open({
+            templateUrl: './cat/bucket/copy/t_apply_tags',
+            animation: true,
+            controller:
+                   ['$scope','$uibModalInstance',
+            function($scope , $uibModalInstance) {
+
+                $scope.tag_map = [];
+
+                egCore.pcrud.retrieveAll('cctt', {order_by : { cctt : 'label' }}, {atomic : true}).then(function(list) {
+                    $scope.tag_types = list;
+                    $scope.tag_type = $scope.tag_types[0].code(); // just pick a default
+                });
+
+                $scope.getTags = function(val) {
+                    return egCore.pcrud.search('acpt',
+                        {
+                            owner :  egCore.org.fullPath(egCore.auth.user().ws_ou(), true),
+                            label : { 'startwith' : {
+                                        transform: 'evergreen.lowercase',
+                                        value : [ 'evergreen.lowercase', val ]
+                                    }},
+                            tag_type : $scope.tag_type
+                        },
+                        { order_by : { 'acpt' : ['label'] } }, { atomic: true }
+                    ).then(function(list) {
+                        return list.map(function(item) {
+                            return item.label();
+                        });
+                    });
+                }
+
+                $scope.addTag = function() {
+                    var tagLabel = $scope.selectedLabel;
+                    // clear the typeahead
+                    $scope.selectedLabel = "";
+
+                    egCore.pcrud.search('acpt',
+                        {
+                            owner : egCore.org.fullPath(egCore.auth.user().ws_ou(), true),
+                            label : tagLabel,
+                            tag_type : $scope.tag_type
+                        },
+                        { order_by : { 'acpt' : ['label'] } }, { atomic: true }
+                    ).then(function(list) {
+                        if (list.length > 0) {
+                            var newMap = new egCore.idl.acptcm();
+                            newMap.isnew(1);
+                            newMap.tag(egCore.idl.Clone(list[0]));
+                            $scope.tag_map.push(newMap);
+                        }
+                    });
+                }
+
+                $scope.ok = function() {
+                    var promises = [];
+                    angular.forEach($scope.tag_map, function(map) {
+                        if (map.isdeleted()) return;
+                        angular.forEach(copies, function (cp) {
+                            var m = new egCore.idl.acptcm();
+                            m.isnew(1);
+                            m.copy(cp.id);
+                            m.tag(map.tag().id());
+                            promises.push(egCore.pcrud.create(m));
+                        });
+                    });
+                    return $q.all(promises).then(function(){$uibModalInstance.close()});
+                }
+
+                $scope.cancel = function($event) {
+                    $uibModalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        });
+    }
+
     // fetch the bucket;  on error show the not-allowed message
     if ($scope.bucketId) 
         drawBucket()['catch'](function() { $scope.forbidden = true });

commit 2f4111d29af1f1372f7ddf5ea21644955dce897c
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu May 11 11:29:25 2017 -0400

    LP#1673857: add ability to set copy tags in volume/copy editor
    
    The copy editor now has a 'Copy Tags' button that can be used
    to assign or remove tags from a copy. A typeahead widget is
    used to allow the user to select an existing tag, but users can
    also use this interface to create an entirely new tag on the fly.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm
index 4b81a46..14a52d4 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm
@@ -259,6 +259,51 @@ sub update_copy_notes {
 }
 
 
+sub update_copy_tags {
+    my($class, $editor, $copy) = @_;
+
+    return undef if $copy->isdeleted;
+
+    my $evt;
+    my $incoming_maps = $copy->tags;
+
+    for my $incoming_map (@$incoming_maps) {
+        next unless $incoming_map;
+
+        if ($incoming_map->isnew) {
+            next if ($incoming_map->isdeleted); # if it was added and deleted in the same session
+
+            my $tag_id;
+            if ($incoming_map->tag->isnew) {
+                my $new_tag = Fieldmapper::asset::copy_tag->new();
+                $new_tag->owner( $incoming_map->tag->owner );
+                $new_tag->label( $incoming_map->tag->label );
+                $new_tag->tag_type( $incoming_map->tag->tag_type );
+                $new_tag->pub( $incoming_map->tag->pub );
+                my $tag = $editor->create_asset_copy_tag($new_tag)
+                    or return $editor->event;
+                $tag_id = $tag->id;
+            } else {
+                $tag_id = $incoming_map->tag->id;
+            }
+            my $new_map = Fieldmapper::asset::copy_tag_copy_map->new();
+            $new_map->copy( $copy->id );
+            $new_map->tag( $tag_id );
+            $incoming_map = $editor->create_asset_copy_tag_copy_map($new_map)
+                or return $editor->event;
+
+        } elsif ($incoming_map->ischanged) {
+            $incoming_map = $editor->update_asset_copy_tag_copy_map($incoming_map)
+        } elsif ($incoming_map->isdeleted) {
+            $incoming_map = $editor->delete_asset_copy_tag_copy_map($incoming_map)
+        }
+    
+    }
+
+    return undef;
+}
+
+
 
 sub update_copy {
     my($class, $editor, $override, $vol, $copy, $retarget_holds, $force_delete_empty_bib) = @_;
@@ -370,6 +415,8 @@ sub update_fleshed_copies {
 
         my $notes = $copy->notes;
         $copy->clear_notes;
+        my $tags = $copy->tags;
+        $copy->clear_tags;
 
         if( $copy->isdeleted ) {
             $evt = $class->delete_copy($editor, $override, $vol, $copy, $retarget_holds, $force_delete_empty_bib);
@@ -394,6 +441,10 @@ sub update_fleshed_copies {
 
         $copy->notes( $notes );
         $evt = $class->update_copy_notes($editor, $copy);
+
+        $copy->tags( $tags );
+        $evt = $class->update_copy_tags($editor, $copy);
+
         return $evt if $evt;
     }
 
diff --git a/Open-ILS/src/templates/staff/cat/volcopy/t_attr_edit.tt2 b/Open-ILS/src/templates/staff/cat/volcopy/t_attr_edit.tt2
index 12f1f77..fc2c5e6 100644
--- a/Open-ILS/src/templates/staff/cat/volcopy/t_attr_edit.tt2
+++ b/Open-ILS/src/templates/staff/cat/volcopy/t_attr_edit.tt2
@@ -401,6 +401,15 @@
                         ng-options="a.id() as a.name() for a in floating_list"
                     ></select>
                 </div>
+                <div class="col-md-6">
+                    <button
+                      class="btn btn-default"
+                      ng-disabled="!defaults.copy_tags"
+                      ng-click="copy_tags_dialog(workingGridControls.selectedItems())"
+                      type="button">
+                        [% l('Copy Tags') %]
+                    </button>
+                </div>
             </div>
         </div>
 
diff --git a/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2 b/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
new file mode 100644
index 0000000..01932ad
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
@@ -0,0 +1,39 @@
+<form ng-submit="ok(note)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">×</button>
+      <h4 class="modal-title">[% l('Manage Copy Tags') %]</h4>
+    </div>
+    <div class="modal-body">
+      <ul>
+        <li ng-repeat="map in tag_map" ng-show="!map.isdeleted()">
+            <span class="copy_tag_label">{{map.tag().label()}}</span>
+            <button type="button" ng-click="map.isdeleted(1)" class="btn btn-xs btn-warning">[% ('Remove') %]</button>
+        </li>
+      </ul>
+      <div class="row">
+        <div class="col-md-12 form-inline">
+          <div class="form-group">
+            <label for="tagType">[% l('Tag Type') %]</label>
+            <select class="form-control" name="tagType" ng-model="tag_type"
+                    ng-options="t.code() as t.label() for t in tag_types"></select>
+          </div>
+          <div class="form-group">
+            <label for="tagLabel">[% l('Tag') %]</label>
+            <input name="tabLabel" type="text" ng-model="selectedLabel" placeholder="[% l('Enter tag label...') %]"
+                uib-typeahead="tag for tag in getTags($viewValue)"
+                class="form-control"></input>
+          </div>
+          <button type="button" class="btn btn-sm btn-default" ng-click="addTag()">[% l('Add Tag') %]</button>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <div class="col-md-12 pull-right">
+          <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+          <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+        </div>
+      </div>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/volcopy/t_defaults.tt2 b/Open-ILS/src/templates/staff/cat/volcopy/t_defaults.tt2
index 44b328b..507205d 100644
--- a/Open-ILS/src/templates/staff/cat/volcopy/t_defaults.tt2
+++ b/Open-ILS/src/templates/staff/cat/volcopy/t_defaults.tt2
@@ -302,6 +302,12 @@
                         [% l('Floating') %]
                     </label>
                 </div>
+                <div class="col-xs-6">
+                    <label>
+                        <input type="checkbox" ng-change="saveDefaults()" ng-model="defaults.copy_tags"/>
+                        [% l('Add/Edit Copy Tags') %]
+                    </label>
+                </div>
             </div>
         </div>
 
diff --git a/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js b/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js
index 644be2f..bbf44a2 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js
@@ -246,8 +246,9 @@ function(egCore , $q) {
     service.flesh = {   
         flesh : 3, 
         flesh_fields : {
-            acp : ['call_number','parts','stat_cat_entries', 'notes'],
-            acn : ['label_class','prefix','suffix']
+            acp : ['call_number','parts','stat_cat_entries', 'notes', 'tags'],
+            acn : ['label_class','prefix','suffix'],
+            acptcm : ['tag']
         }
     }
 
@@ -786,6 +787,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         auto_gen_barcode : false,
         statcats : true,
         copy_notes : true,
+        copy_tags : true,
         attributes : {
             status : true,
             loan_duration : true,
@@ -1658,6 +1660,125 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         });
     }
 
+    $scope.copy_tags_dialog = function(copy_list) {
+        if (!angular.isArray(copy_list)) copy_list = [copy_list];
+
+        return $uibModal.open({
+            templateUrl: './cat/volcopy/t_copy_tags',
+            animation: true,
+            controller:
+                   ['$scope','$uibModalInstance',
+            function($scope , $uibModalInstance) {
+
+                $scope.tag_map = [];
+                var tag_hash = {};
+                var shared_tags = {};
+                angular.forEach(copy_list, function (cp) {
+                    angular.forEach(cp.tags(), function(tag) {
+                        if (!(tag.tag().id() in shared_tags)) {
+                            shared_tags[tag.tag().id()] = 1;
+                        } else {
+                            shared_tags[tag.tag().id()]++;
+                        }
+                        if (!(tag.tag().id() in tag_hash)) {
+                            tag_hash[tag.tag().id()] = tag;
+                        }
+                    });
+                });
+                angular.forEach(tag_hash, function(value, key) {
+                    if (shared_tags[key] == copy_list.length) {
+                        $scope.tag_map.push(value);
+                    }
+                });
+
+                $scope.tag_types = [];
+                egCore.pcrud.retrieveAll('cctt', {order_by : { cctt : 'label' }}, {atomic : true}).then(function(list) {
+                    $scope.tag_types = list;
+                    $scope.tag_type = $scope.tag_types[0].code(); // just pick a default
+                });
+
+                $scope.getTags = function(val) {
+                    return egCore.pcrud.search('acpt',
+                        { 
+                            owner :  egCore.org.fullPath(egCore.auth.user().ws_ou(), true),
+                            label : { 'startwith' : {
+                                        transform: 'evergreen.lowercase',
+                                        value : [ 'evergreen.lowercase', val ]
+                                    }},
+                            tag_type : $scope.tag_type
+                        },
+                        { order_by : { 'acpt' : ['label'] } }, { atomic: true }
+                    ).then(function(list) {
+                        return list.map(function(item) {
+                            return item.label();
+                        });
+                    });
+                }
+
+                $scope.addTag = function() {
+                    var tagLabel = $scope.selectedLabel;
+                    // clear the typeahead
+                    $scope.selectedLabel = "";
+
+                    // first, check tags already associated with the copy
+                    var foundMatch = false;
+                    angular.forEach($scope.tag_map, function(tag) {
+                        if (tag.tag().label() ==  tagLabel && tag.tag().tag_type() == $scope.tag_type) {
+                            foundMatch = true;
+                            if (tag.isdeleted()) tag.isdeleted(0); // just deleting the mapping
+                        }
+                    });
+                    if (!foundMatch) {
+                        egCore.pcrud.search('acpt',
+                            { 
+                                owner : egCore.org.fullPath(egCore.auth.user().ws_ou(), true),
+                                label : tagLabel,
+                                tag_type : $scope.tag_type
+                            },
+                            { order_by : { 'acpt' : ['label'] } }, { atomic: true }
+                        ).then(function(list) {
+                            if (list.length > 0) {
+                                var newMap = new egCore.idl.acptcm();
+                                newMap.isnew(1);
+                                newMap.copy(copy_list[0].id());
+                                newMap.tag(egCore.idl.Clone(list[0]));
+                                $scope.tag_map.push(newMap);
+                            } else {
+                                var newTag = new egCore.idl.acpt();
+                                newTag.isnew(1);
+                                newTag.owner(egCore.auth.user().ws_ou());
+                                newTag.label(tagLabel);
+                                newTag.pub('t');
+                                newTag.tag_type($scope.tag_type);
+
+                                var newMap = new egCore.idl.acptcm();
+                                newMap.isnew(1);
+                                newMap.copy(copy_list[0].id());
+                                newMap.tag(newTag);
+                                $scope.tag_map.push(newMap);
+                            }
+                        });
+                    }
+                }
+
+                $scope.ok = function(note) {
+                    // in the multi-item case, this works OK for
+                    // adding new maps to existing tags, but doesn't handle
+                    // all possibilities
+                    angular.forEach(copy_list, function (cp) {
+                        cp.tags($scope.tag_map);
+                    });
+                    $uibModalInstance.close();
+                }
+
+                $scope.cancel = function($event) {
+                    $uibModalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        });
+    }
+
 }])
 
 .directive("egVolTemplate", function () {
@@ -1676,6 +1797,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                     auto_gen_barcode : false,
                     statcats : true,
                     copy_notes : true,
+                    copy_tags : true,
                     attributes : {
                         status : true,
                         loan_duration : true,

commit 8f6e436a78efc5aa11164dadb5c868f34b0f2d19
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Mar 30 17:08:19 2017 -0400

    LP#1673857: admin interfaces for copy tag types and copy tags
    
    This patch adds standard administration interfaces to manage
    copy tag types (Server Administration) and copy tags (Local
    Administration)
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/templates/staff/admin/local/asset/copy_tag.tt2 b/Open-ILS/src/templates/staff/admin/local/asset/copy_tag.tt2
new file mode 100644
index 0000000..82d93bc
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/local/asset/copy_tag.tt2
@@ -0,0 +1,50 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Copy Tags");
+  ctx.page_app = "egAdminConfig";
+  ctx.page_ctrl = 'CopyTag';
+%]
+
+[% 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/fm_record_editor.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/local/asset/copy_tag.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/admin.css" />
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    [% l('Copy Tags') %]
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-4">
+    <div class="form-group">
+      <label>[% l('Filter by Owning Library:') %]</label>
+      <eg-org-selector selected="context_org" onchange="org_changed"></eg-org-selector>
+    </div>
+  </div>
+</div>
+
+<eg-grid
+    id-field="id"
+    idl-class="acpt"
+    grid-controls="gridControls"
+    features="-multiselect"
+    persist-key="admin.server.asset.copy_tag">
+
+    <eg-grid-menu-item handler="new_record" label="[% l('New Record') %]"></eg-grid-menu-item>
+    <eg-grid-action handler="edit_record" label="[% l('Edit Record') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_record" label="[% l('Delete Record') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Owner') %]" flesher="orgById" path="owner.name"></eg-grid-field>
+    <eg-grid-field label="[% l('Tag Type') %]"  path="tag_type.label"></eg-grid-field>
+    <eg-grid-field label="[% l('Label') %]" path="label"></eg-grid-field>
+    <eg-grid-field label="[% l('Is OPAC Visible?') %]" path="pub"></eg-grid-field>
+    <eg-grid-field label="[% l('ID') %]" path='id' required hidden></eg-grid-field>
+    <eg-grid-field path='*' hidden></eg-grid-field>
+</eg-grid>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/local/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
index 22878a1..cdcccb7 100644
--- a/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
+++ b/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
@@ -20,6 +20,7 @@
     ,[ l('Copy Location Groups'), "./admin/local/asset/copy_location_group" ]
     ,[ l('Copy Location Order'), "./admin/local/asset/copy_location_order" ]
     ,[ l('Copy Locations Editor'), "./admin/local/asset/copy_locations" ]
+    ,[ l('Copy Tags'), "./admin/local/asset/copy_tag" ]
     ,[ l('Field Documentation'), "./admin/local/config/idl_field_doc" ]
     ,[ l('Group Penalty Thresholds'), "./admin/local/permission/grp_penalty_threshold" ]
     ,[ l('Hold Policies'), "./admin/local/config/hold_matrix_matchpoint" ]
diff --git a/Open-ILS/src/templates/staff/admin/server/config/copy_tag_type.tt2 b/Open-ILS/src/templates/staff/admin/server/config/copy_tag_type.tt2
new file mode 100644
index 0000000..676083d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/server/config/copy_tag_type.tt2
@@ -0,0 +1,38 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Copy Tag Types");
+  ctx.page_app = "egAdminConfig";
+  ctx.page_ctrl = 'CopyTagType';
+%]
+
+[% 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/fm_record_editor.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/server/config/copy_tag_type.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/admin.css" />
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    [% l('Copy Tag Types') %]
+  </div>
+</div>
+
+<eg-grid
+    id-field="code"
+    idl-class="cctt"
+    grid-controls="gridControls"
+    features="-multiselect"
+    persist-key="admin.server.config.copy_tag_type">
+
+    <eg-grid-menu-item handler="new_record" label="[% l('New Record') %]"></eg-grid-menu-item>
+    <eg-grid-action handler="edit_record" label="[% l('Edit Record') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_record" label="[% l('Delete Record') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Code') %]"  path="code"></eg-grid-field>
+    <eg-grid-field label="[% l('Label') %]" path="label"></eg-grid-field>
+    <eg-grid-field label="[% l('Owner') %]" flesher="orgById" path="owner.name"></eg-grid-field>
+</eg-grid>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/server/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/server/t_splash.tt2
index 5d8bf43..36faae3 100644
--- a/Open-ILS/src/templates/staff/admin/server/t_splash.tt2
+++ b/Open-ILS/src/templates/staff/admin/server/t_splash.tt2
@@ -26,6 +26,7 @@
     ,[ l('Circulation Modifiers'), "./admin/server/config/circ_modifier" ]
     ,[ l('Circulation Recurring Fine Rules'), "./admin/server/config/rule_recurring_fine" ]
     ,[ l('Copy Statuses'), "./admin/server/legacy/config/copy_status" ]
+    ,[ l('Copy Tag Types'), "./admin/server/config/copy_tag_type" ]
     ,[ l('Custom Org Unit Trees'), "./admin/server/actor/org_unit_custom_tree" ]
     ,[ l('Floating Groups'), "./admin/server/config/floating_groups" ]
     ,[ l('Global Flags'), "./admin/server/config/global_flag" ]
diff --git a/Open-ILS/web/js/ui/default/staff/admin/local/asset/copy_tag.js b/Open-ILS/web/js/ui/default/staff/admin/local/asset/copy_tag.js
new file mode 100644
index 0000000..3d9ca2c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/local/asset/copy_tag.js
@@ -0,0 +1,90 @@
+angular.module('egAdminConfig',
+    ['ngRoute','ui.bootstrap','egCoreMod','egUiMod','egGridMod','egFmRecordEditorMod'])
+
+.controller('CopyTag',
+       ['$scope','$q','$timeout','$location','$window','$uibModal','egCore','egGridDataProvider',
+        'egConfirmDialog',
+function($scope , $q , $timeout , $location , $window , $uibModal , egCore , egGridDataProvider ,
+         egConfirmDialog) {
+
+    egCore.startup.go(); // standalone mode requires manual startup
+
+    $scope.new_record = function() {
+        spawn_editor();
+    }
+
+    $scope.edit_record = function(items) {
+        if (items.length != 1) return;
+        spawn_editor(items[0].id);
+    }
+
+    spawn_editor = function(id) {
+        var templ;
+        if (arguments.length == 1) {
+            templ = '<eg-edit-fm-record idl-class="acpt" mode="update" record-id="id" on-save="ok" on-cancel="cancel"></eg-edit-fm-record>';
+        } else {
+            templ = '<eg-edit-fm-record idl-class="acpt" mode="create" on-save="ok" on-cancel="cancel"></eg-edit-fm-record>';
+        }
+        gridControls = $scope.gridControls;
+        $uibModal.open({
+            template : templ,
+            controller : [
+                        '$scope', '$uibModalInstance',
+                function($scope ,  $uibModalInstance) {
+                    $scope.id = id;
+
+                    $scope.ok = function($event) {
+                        $uibModalInstance.close();
+                        gridControls.refresh();
+                    }
+    
+                    $scope.cancel = function($event) {
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        });
+    }
+
+    $scope.delete_record = function(selected) {
+        if (!selected || !selected.length) return;
+
+        egCore.pcrud.retrieve('acpt', selected[0].id).then(function(rec) {
+            egConfirmDialog.open(
+                egCore.strings.EG_CONFIRM_DELETE_RECORD_TITLE,
+                egCore.strings.EG_CONFIRM_DELETE_RECORD_BODY,
+                { id : rec.id() }
+            ).result.then(function() {
+                egCore.pcrud.remove(rec).then(function() {
+                    $scope.gridControls.refresh();
+                });
+            });
+        });
+    }
+
+    function generateQuery(orgId) {
+
+        // because the orgId is coming from a selector,
+        // it should always have a value unless the selector
+        // hasn't been fully initialized yet, in which case
+        // we want to abort to avoid fetching anything.
+        if (!orgId) return;
+
+        return {
+            'id' : { '!=' : null },
+            'owner' : egCore.org.descendants(orgId, true)
+        };
+    }
+    $scope.gridControls = {
+        setQuery : function() { return generateQuery(); },
+        setSort : function() {
+            return ['owner.name', 'label'];
+        }
+    }
+
+    $scope.org_changed = function(org) {
+        $scope.gridControls.setQuery(generateQuery(org.id()));
+        $scope.gridControls.refresh();
+    }
+
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/admin/server/config/copy_tag_type.js b/Open-ILS/web/js/ui/default/staff/admin/server/config/copy_tag_type.js
new file mode 100644
index 0000000..5d367eb
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/server/config/copy_tag_type.js
@@ -0,0 +1,73 @@
+angular.module('egAdminConfig',
+    ['ngRoute','ui.bootstrap','egCoreMod','egUiMod','egGridMod','egFmRecordEditorMod'])
+
+.controller('CopyTagType',
+       ['$scope','$q','$timeout','$location','$window','$uibModal','egCore','egGridDataProvider',
+        'egConfirmDialog',
+function($scope , $q , $timeout , $location , $window , $uibModal , egCore , egGridDataProvider ,
+         egConfirmDialog) {
+
+    egCore.startup.go(); // standalone mode requires manual startup
+
+    $scope.new_record = function() {
+        spawn_editor();
+    }
+
+    $scope.edit_record = function(items) {
+        if (items.length != 1) return;
+        spawn_editor(items[0].code);
+    }
+
+    spawn_editor = function(code) {
+        var templ;
+        if (arguments.length == 1) {
+            templ = '<eg-edit-fm-record idl-class="cctt" mode="update" record-id="code" on-save="ok" on-cancel="cancel"></eg-edit-fm-record>';
+        } else {
+            templ = '<eg-edit-fm-record idl-class="cctt" mode="create" on-save="ok" on-cancel="cancel"></eg-edit-fm-record>';
+        }
+        gridControls = $scope.gridControls;
+        $uibModal.open({
+            template : templ,
+            controller : [
+                        '$scope', '$uibModalInstance',
+                function($scope ,  $uibModalInstance) {
+                    $scope.code = code;
+
+                    $scope.ok = function($event) {
+                        $uibModalInstance.close();
+                        gridControls.refresh();
+                    }
+    
+                    $scope.cancel = function($event) {
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        });
+    }
+
+    $scope.delete_record = function(selected) {
+        if (!selected || !selected.length) return;
+
+        egCore.pcrud.retrieve('cctt', selected[0].code).then(function(rec) {
+            egConfirmDialog.open(
+                egCore.strings.EG_CONFIRM_DELETE_RECORD_TITLE,
+                egCore.strings.EG_CONFIRM_DELETE_RECORD_BODY,
+                { code : rec.code() }
+            ).result.then(function() {
+                egCore.pcrud.remove(rec).then(function() {
+                    $scope.gridControls.refresh();
+                });
+            });
+        });
+    }
+
+    $scope.gridControls = {
+        setQuery : function() {
+            return { 'code' : { '!=' : null } };
+        },
+        setSort : function() {
+            return ['code'];
+        }
+    }
+}])

commit 0b08d83e6f05ff863ef85c63cb86f5b7f39b5c23
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Mon Apr 10 18:33:34 2017 +0000

    LP#1673857: teach egFmRecordEditor how to create non-sequence-controlled ID values
    
    Since config.copy_tag_type uses a natural key, the egFmRecordEditor
    dialog needs to allow the user to set it when creating a new
    type.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2 b/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
index f7a7a6e..f2328a1 100644
--- a/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
+++ b/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
@@ -11,7 +11,13 @@
         <label for="rec-{{field.name}}">{{field.label}}</label>
       </div>
       <div class="col-md-9">
-        <span  ng-if="field.datatype == 'id'">{{rec[field.name]()}}</span>
+        <span  ng-if="field.datatype == 'id' && !id_is_editable">{{rec[field.name]()}}</span>
+        <input ng-if="field.datatype == 'id' &&  id_is_editable"
+          ng-readonly="field.readonly"
+          ng-required="field.is_required()"
+          ng-model="rec[field.name]"
+          ng-model-options="{ getterSetter : true }">
+        </input>
         <input ng-if="field.datatype == 'text'"
           ng-readonly="field.readonly"
           ng-required="field.is_required()"
diff --git a/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js b/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
index be04844..8157f18 100644
--- a/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
+++ b/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
@@ -75,6 +75,7 @@ angular.module('egFmRecordEditorMod',
             $scope.record_label = egCore.idl.classes[$scope.idlClass].label;
             $scope.rec_orgs = {};
             $scope.rec_org_values = {};
+            $scope.id_is_editable = false;
 
             if ($scope.mode == 'update') {
                 egCore.pcrud.retrieve($scope.idlClass, $scope.recordId).then(function(r) {
@@ -83,6 +84,9 @@ angular.module('egFmRecordEditorMod',
                     $scope.fields = get_field_list();
                 });
             } else {
+                if (!('pkey_sequence' in egCore.idl.classes[$scope.idlClass])) {
+                    $scope.id_is_editable = true;
+                }
                 $scope.rec = new egCore.idl[$scope.idlClass]();
                 $scope.fields = get_field_list();
             }

commit 12894c4f76f1ee6e8f2a58ddabe27c39e281a8be
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed Mar 29 16:15:31 2017 -0400

    LP#1673857: teach catalog how to search and display copy tags
    
    When the opac.search.enable_bookplate_search library setting is
    set to true, the catalog will display a "Digital Bookplates" search
    field in the drop-downs on both the search bar and the advanced search
    page. Using this will add a "copy_tag(*, search_terms)" filter
    to the search, i.e., all visible copy tags will be searched regardless
    of type.  Users can also use the copy_tag() search filter directly.
    
    Visible copy tags are displayed in the copy table in the record
    summary page. Note that copy tags are displayed regardless of whether
    or not opac.search.enable_bookplate_search is on or off.
    
    Mike Rylander also contributed to this patch.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm
index 114de0c..a7f56a2 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm
@@ -102,6 +102,7 @@ sub load_record {
     $ctx->{copies} = $copy_rec->gather(1);
 
     # Add public copy notes to each copy - and while we're in there, grab peer bib records
+    # and copy tags
     my %cached_bibs = ();
     foreach my $copy (@{$ctx->{copies}}) {
         $copy->{notes} = $U->simplereq(
@@ -110,6 +111,19 @@ sub load_record {
             {itemid => $copy->{id}, pub => 1 }
         );
         $self->timelog("past copy note retrieval call");
+        my $meth = 'open-ils.circ.copy_tags.retrieve';
+        $meth .= ".staff" if $ctx->{is_staff};
+        $copy->{tags} = $U->simplereq(
+            'open-ils.circ',
+            $meth,
+            {
+                ($ctx->{is_staff} ? (authtoken => $ctx->{authtoken}) : ()),
+                copy_id  => $copy->{id},
+                scope    => $org,
+                depth    => $copy_depth,
+            }
+        );
+        $self->timelog("past copy tag retrieval call");
         $copy->{peer_bibs} = $U->simplereq(
             'open-ils.search',
             'open-ils.search.multi_home.bib_ids.by_barcode',
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm
index 72faa1c..fa72bc9 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm
@@ -20,11 +20,21 @@ sub _prepare_biblio_search_basics {
     $parts{$_} = [ $cgi->param($_) ] for (@part_names);
 
     my $full_query = '';
+    my @bookplate_queries = ();
     for (my $i = 0; $i < scalar @{$parts{'qtype'}}; $i++) {
         my ($qtype, $contains, $query, $bool) = map { $parts{$_}->[$i] } @part_names;
 
         next unless $query =~ /\S/;
 
+        # Hack for bookplates; "bookplate" is not a real search
+        # class, so grabbing them out of the advanced search query
+        # params to convert to a copy_tag(*,...) filter later
+        if ($qtype eq 'bookplate') {
+            $query =~ s/[)(]/ /g; # don't break on 'foo(bar)baz'
+            push @bookplate_queries, $query;
+            next;
+        }
+ 
         # Hack for journal title
         my $jtitle = 0;
         if ($qtype eq 'jtitle') {
@@ -63,15 +73,23 @@ sub _prepare_biblio_search_basics {
         $full_query = $full_query ? "($full_query $bool $query)" : $query;
     }
 
-    return $full_query;
+    return $full_query, \@bookplate_queries;
 }
 
 sub _prepare_biblio_search {
     my ($cgi, $ctx) = @_;
 
     # XXX This will still contain the jtitle hack...
-    my $user_query = _prepare_biblio_search_basics($cgi) || '';
+    my ($user_query, $bookplate_queries) = _prepare_biblio_search_basics($cgi);
+    $user_query //= '';
+    $bookplate_queries //= [];
     my $query = $user_query;
+    if (@$bookplate_queries) {
+        $query .= " " . join(" ", map { "copy_tag(*,$_)" } @$bookplate_queries);
+        # hack to handle the case where a bookplate comes from the
+        # simple search box
+        $user_query = $bookplate_queries->[0] if $user_query eq '';
+    }
 
     $query .= ' ' . $ctx->{global_search_filter} if $ctx->{global_search_filter};
 
diff --git a/Open-ILS/src/templates/opac/css/style.css.tt2 b/Open-ILS/src/templates/opac/css/style.css.tt2
index a8884db..226b294 100644
--- a/Open-ILS/src/templates/opac/css/style.css.tt2
+++ b/Open-ILS/src/templates/opac/css/style.css.tt2
@@ -714,6 +714,15 @@ div.format_icon {
     word-wrap:normal;
 }
 
+#rdetails_status tbody td.copy_tag {
+    border-color: [% css_colors.primary %];
+    border-style: dashed;
+    border-width: 2px;
+}
+#rdetails_status tbody .copy_tag_value {
+    font-weight: bolder;
+}
+
 .rdetail_extras {
     background-color: [% css_colors.primary_fade %];
     border: 1px solid [% css_colors.primary %];
diff --git a/Open-ILS/src/templates/opac/parts/qtype_selector.tt2 b/Open-ILS/src/templates/opac/parts/qtype_selector.tt2
index 4ed9e63..ff3247e 100644
--- a/Open-ILS/src/templates/opac/parts/qtype_selector.tt2
+++ b/Open-ILS/src/templates/opac/parts/qtype_selector.tt2
@@ -5,7 +5,13 @@
     {value => "author", label => l("Author"), plural_label => l("Authors"), browse => 1},
     {value => "subject", label => l("Subject"), plural_label => l("Subjects"), browse => 1},
     {value => "series", label => l("Series"), plural_label => l("Series"), browse => 1}
-] %]
+];
+    IF  ctx.get_org_setting(ctx.search_ou, 'opac.search.enable_bookplate_search');
+        query_types.push(
+            {value => "bookplate", label => l("Digital Bookplate"), plural_label => l("Digital Bookplates")}
+        );
+    END;
+-%]
 <select name="[% name || 'qtype' %]"[% IF id; ' id="'; id ; '"' ; END -%]
     title="[% l('Select query type:') %]">
     [%  query_type = query_type || CGI.param('qtype') || search.default_qtypes.0;
diff --git a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
index 3205b0e..8636fe9 100644
--- a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
+++ b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
@@ -231,6 +231,17 @@ END; # FOREACH bib
             [% END %]
         [% END %]
 
+        [% IF copy_info.tags; %]
+            [% FOREACH tag IN copy_info.tags; %]
+                <tr class="copy_tag_row">
+                    <td> </td>
+                    <td class="copy_tag" colspan="4"</td>
+                        <span class="copy_tag_value">[% tag.value | html %]</span>
+                    </td>
+                <tr>
+            [% END %]
+        [% END %]
+
 <tr><td>
 [%- IF copy_info.peer_bib_marc.size > 1;
 '<ul>';

commit 1bf0a255cac45c82d909bacb214d2649bb63edda
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed Mar 29 15:46:54 2017 -0400

    LP#1673857: add open-ils.circ.copy_tags.retrieve[.staff]
    
    These are methods to retrieve the set of copy tags associated
    with a copy, scoped to an OU and its descendents at a
    optional depth. The .staff version includes both
    public and non-public notes (and requires STAFF_LOGIN
    permission).
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
index 8f439e5..e24a3e9 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
@@ -1003,6 +1003,74 @@ sub delete_copy_note {
     return 1;
 }
 
+__PACKAGE__->register_method(
+    method      => 'fetch_copy_tags',
+    authoritative   => 1,
+    api_name        => 'open-ils.circ.copy_tags.retrieve',
+    signature   => q/
+        Returns an array of publicly-visible copy tag objects.  
+        @param args A named hash of parameters including:
+            copy_id     : The id of the item whose notes we want to retrieve
+            tag_type    : Type of copy tags to retrieve, e.g., 'bookplate' (optional)
+            scope       : top of org subtree whose copy tags we want to see
+            depth       : how far down to look for copy tags (optional)
+        @return An array of copy tag objects
+    /);
+__PACKAGE__->register_method(
+    method      => 'fetch_copy_tags',
+    authoritative   => 1,
+    api_name        => 'open-ils.circ.copy_tags.retrieve.staff',
+    signature   => q/
+        Returns an array of all copy tag objects.  
+        @param args A named hash of parameters including:
+            authtoken   : Required to view non-public notes
+            copy_id     : The id of the item whose notes we want to retrieve (optional)
+            tag_type    : Type of copy tags to retrieve, e.g., 'bookplate'
+            scope       : top of org subtree whose copy tags we want to see
+            depth       : how far down to look for copy tags (optional)
+        @return An array of copy tag objects
+    /);
+
+sub fetch_copy_tags {
+    my ($self, $conn, $args) = @_;
+
+    my $org = $args->{scope};
+    my $depth = $args->{depth};
+
+    my $filter = {};
+    my $e;
+    if ($self->api_name =~ /\.staff/) {
+        my $authtoken = $args->{authtoken};
+        return new OpenILS::Event("BAD_PARAMS", "desc" => "authtoken required") unless defined $authtoken;    
+        $e = new_editor(authtoken => $args->{authtoken});
+        return $e->event unless $e->checkauth;
+        return $e->event unless $e->allowed('STAFF_LOGIN', $org);
+    } else {
+        $e = new_editor();
+        $filter->{pub} = 't';
+    }
+    $filter->{tag_type} = $args->{tag_type} if exists($args->{tag_type});
+    $filter->{'+acptcm'} = {
+        copy => $args->{copy_id}
+    };
+
+    # filter by owner of copy tag and depth
+    $filter->{owner} = {
+        in => {
+            select => {aou => [{
+                column => 'id',
+                transform => 'actor.org_unit_descendants',
+                result_field => 'id',
+                (defined($depth) ? ( params => [$depth] ) : ()),
+            }]},
+            from => 'aou',
+            where => {id => $org}
+        }
+    };
+
+    return $e->search_asset_copy_tag([$filter, { join => { acptcm => {} } }]);
+}
+
 
 __PACKAGE__->register_method(
     method => 'age_hold_rules',

commit e02f34dbe844d1cd2193486593f832af3193ecd7
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Mar 17 17:46:59 2017 -0400

    LP#1673857: add search filter for copy_tags
    
    Copy tags can be used as a search filter in the catalog. Two
    variations are supported:
    
    * copy_tag(type_code, search_terms)
    
      Search for records that have copies that are linked to tags
      whose value matches the search terms and whose type's
      config.copy_tag_type.code matches the specified
      type_code. E.g., "copy_tag(bookplate, donated by jane smith)"
    
    * copy_tag(*, search_terms)
    
      Search for records that have copies that are linked to tags
      whose value matches the search terms, regardless of type.
    
    The copy_tag() search filter takes the OPAC-visiblity (as determined
    by asset.copy_tag.pub) of the tag into account.
    
    Mike Rylander also contributed to this patch.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
index 0f7c1f0..babaa46 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
@@ -688,6 +688,9 @@ __PACKAGE__->add_search_filter( 'record_list' );
 
 __PACKAGE__->add_search_filter( 'has_browse_entry' );
 
+# copy_tag(copy_tag_code,copy_tag_search)
+__PACKAGE__->add_search_filter( 'copy_tag' );
+
 # used internally, but generally not user-settable
 __PACKAGE__->add_search_filter( 'preferred_language' );
 __PACKAGE__->add_search_filter( 'preferred_language_weight' );
@@ -713,6 +716,7 @@ use OpenSRF::Utils::Logger qw($logger);
 use OpenSRF::Utils qw/:datetime/;
 use Data::Dumper;
 use OpenILS::Application::AppUtils;
+use OpenILS::Utils::Normalize qw/search_normalize/;
 my $apputils = "OpenILS::Application::AppUtils";
 
 our %_dfilter_controlled_cache = ();
@@ -1300,6 +1304,51 @@ sub flatten {
                         }
                     }
                 }
+            } elsif ($filter->name eq 'copy_tag') {
+                my $valid_copy_tag_search = 0;
+                my $copy_tag_type;
+                my $tag_value;
+                if (@{$filter->args} >= 2) { # must have at least two parts, tag (or *) and terms
+                    my @fargs = @{$filter->args};
+                    $copy_tag_type = shift(@fargs);
+                    $tag_value = join(' ', @fargs);
+                    $valid_copy_tag_search = 1;
+                }
+                if ($valid_copy_tag_search) {
+                    my $norm_value = search_normalize($tag_value);
+                    my @tokens = split /\s+/, $norm_value;
+                    
+                    my $filter_alias = "$filter";
+                    $filter_alias =~ s/^.*\(0(x[0-9a-fA-F]+)\)$/$1/go;
+                    $filter_alias =~ s/\|/_/go;
+
+                    $with .= ",\n     " if $with;
+                    $with .= "copy_tag_${filter_alias} AS (\n";
+                    $with .= "       SELECT cn.record AS record FROM config.copy_tag_type cctt\n";
+                    $with .= "             JOIN asset.copy_tag acpt ON (cctt.code = acpt.tag_type)\n";
+                    $with .= "             JOIN asset.copy_tag_copy_map acptcm ON (acpt.id = acptcm.tag)\n";
+                    $with .= "             JOIN asset.copy cp ON (acptcm.copy = cp.id)\n";
+                    $with .= "             JOIN asset.call_number cn ON (cp.call_number = cn.id)\n";
+                    $with .= "       WHERE 1 = 1 \n";
+                    if ($copy_tag_type ne '*') {
+                        $with .= "             AND cctt.code = " . $self->QueryParser->quote_value($copy_tag_type) . "\n";
+                    }
+                    if (@tokens) {
+                        $with .= '             AND acpt.value @@ to_tsquery(' . $self->QueryParser->quote_value(join(' & ', @tokens)) . ")\n";
+                    }
+                    if (!$self->find_modifier('staff')) {
+                        $with .= "             AND acpt.pub IS TRUE\n";
+                    }
+                    $with .= "     )";
+
+                    my $optimize_join = 1 if $self->top_plan and !$NOT;
+                    $from .= "\n" . ${spc} x 3 . ( $optimize_join ? 'INNER' : 'LEFT') . " JOIN copy_tag_${filter_alias} ON copy_tag_${filter_alias}.record = m.source";
+
+                    if (!$optimize_join) {
+                        $where .= $joiner if $where ne '';
+                        $where .= "(copy_tag_${filter_alias} IS " . ( $NOT ? 'NULL)' : 'NOT NULL)');
+                    }
+                }
             } elsif ($filter->name eq 'record_list') {
                 if (@{$filter->args} > 0) {
                     my $key = 'm.source';

commit 0da6edee161f256f3d167d489bc7e9922e030548
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Mar 17 17:46:37 2017 -0400

    LP#1673857: schema, IDL, library settings & perms for copy tags
    
    Three new tables are added to store copy tags:
    
    * config.copy_tag_type
    
      Defines types that can be used for distinguishing between
      classes of copy tags when searching the catalog. The
      seed data includes a 'bookplate' type by default. The new
      permission ADMIN_COPY_TAG_TYPES controls C/U/D access to this
      table.
    
    * asset.copy_tag
    
      The actual copy tag values. Copy tags have both labels and values,
      and since at least one interface allows creating copy tags
      on the fly, a trigger will set the value of a new tag
      to its label if the value is null. asset.copy_tag also has a flag
      for setting whether given tag should be searchable (and visible)
      in the public catalog or not. The new permission ADMIN_COPY_TAG
      controls C/U/D access to this table.
    
    * asset.copy_tag_copy_map
    
      This stores the link between copies and their tags. Only the
      UPDATE_COPY permission is required in order to set tag mappings.
    
    The new library setting is opac.search.enable_bookplate_search, which
    controls whether or not to display a "Digital Bookplate" entry in the
    catalog search fields dropdowns.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Josh Stompro <stomproj at larl.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    
    Conflicts:
    	Open-ILS/src/sql/Pg/950.data.seed-values.sql
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index f84734a..bc99527 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -6854,6 +6854,7 @@ SELECT  usr,
 			<field reporter:label="Peer Records" name="peer_records" oils_persist:virtual="true" reporter:datatype="link"/>
 			<field reporter:label="Last Captured Hold" name="last_captured_hold" oils_persist:virtual="true" reporter:datatype="link"/>
 			<field reporter:label="Has Holds" name="holds_count" oils_persist:virtual="true" reporter:datatype="link"/>
+			<field reporter:label="Copy Tags" name="tags" oils_persist:virtual="true" reporter:datatype="link"/>
 		</fields>
 		<links>
 			<link field="age_protect" reltype="has_a" key="id" map="" class="crahp"/>
@@ -6879,6 +6880,7 @@ SELECT  usr,
 			<link field="last_captured_hold" reltype="has_a" key="current_copy" map="" class="alhr"/>
 			<link field="floating" reltype="has_a" key="id" map="" class="cfg"/>
 			<link field="holds_count" reltype="might_have" key="id" map="" class="hasholdscount"/>
+			<link field="tags" reltype="has_many" key="copy" map="" class="acptcm"/>
 		</links>
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
@@ -11608,6 +11610,72 @@ SELECT  usr,
 			</actions>
 		</permacrud>
 	</class>
+	<class id="cctt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::copy_tag_type" oils_persist:tablename="config.copy_tag_type" reporter:label="Copy Tag Types" oils_persist:field_safe="true">
+		<fields oils_persist:primary="code">
+			<field reporter:label="Code" name="code" reporter:selector="label" reporter:datatype="id" oils_obj:required="true"/>
+			<field reporter:label="Label" name="label" reporter:datatype="text" oils_obj:required="true"/>
+			<field reporter:label="Owner" name="owner" reporter:datatype="org_unit" oils_obj:required="true"/>
+		</fields>
+		<links>
+			<link field="owner" reltype="has_a" key="id" map="" class="aou"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="ADMIN_COPY_TAG_TYPES" context_field="owner"/>
+				<retrieve/>
+				<update permission="ADMIN_COPY_TAG_TYPES" context_field="owner"/>
+				<delete permission="ADMIN_COPY_TAG_TYPES" context_field="owner"/>
+			</actions>
+		</permacrud>
+	</class>
+	<class id="acpt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_tag" oils_persist:tablename="asset.copy_tag" reporter:label="Copy Tags" oils_persist:field_safe="true">
+		<fields oils_persist:primary="id" oils_persist:sequence="asset.copy_tag_id_seq">
+			<field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="Copy Tag Type" name="tag_type" reporter:datatype="link"/>
+			<field reporter:label="Label" name="label" reporter:datatype="text"/>
+			<field reporter:label="Value" name="value" reporter:datatype="text"/>
+			<field reporter:label="Staff Note" name="staff_note" reporter:datatype="text"/>
+			<field reporter:label="Is OPAC Visible?" name="pub" reporter:datatype="bool"/>
+			<field reporter:label="Owner" name="owner" reporter:datatype="org_unit" oils_obj:required="true"/>
+		</fields>
+		<links>
+			<link field="tag_type" reltype="has_a" key="code" map="" class="cctt"/>
+			<link field="owner" reltype="has_a" key="id" map="" class="aou"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="ADMIN_COPY_TAGS" context_field="owner"/>
+				<retrieve/>
+				<update permission="ADMIN_COPY_TAGS" context_field="owner"/>
+				<delete permission="ADMIN_COPY_TAGS" context_field="owner"/>
+			</actions>
+		</permacrud>
+	</class>
+	<class id="acptcm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_tag_copy_map" oils_persist:tablename="asset.copy_tag_copy_map" reporter:label="Copy Tag Copy Map" oils_persist:field_safe="true">
+		<fields oils_persist:primary="id" oils_persist:sequence="asset.copy_tag_copy_map_id_seq">
+			<field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="Copy" name="copy" reporter:datatype="link"/>
+			<field reporter:label="Tag" name="tag" reporter:datatype="link"/>
+		</fields>
+		<links>
+			<link field="copy" reltype="has_a" key="id" map="" class="acp"/>
+			<link field="tag" reltype="has_a" key="id" map="" class="acpt"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="UPDATE_COPY">
+                    <context link="copy" field="circ_lib"/>
+                </create>
+				<retrieve/>
+				<update permission="UPDATE_COPY">
+                    <context link="copy" field="circ_lib"/>
+                </update>
+				<delete permission="UPDATE_COPY">
+                    <context link="copy" field="circ_lib"/>
+                </delete>
+			</actions>
+		</permacrud>
+	</class>
 	<class id="hasholdscount" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::has_holds_count" reporter:label="Copy Has Holds Count" oils_persist:readonly="true">
         <oils_persist:source_definition>
 	SELECT ahcm.target_copy AS id,count(*) AS count
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 07d491e..9edf3b1 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -1219,4 +1219,13 @@ ALTER TABLE config.marc_subfield
            )
           );
 
+CREATE TABLE config.copy_tag_type (
+    code            TEXT NOT NULL PRIMARY KEY,
+    label           TEXT NOT NULL,
+    owner           INTEGER NOT NULL -- REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
+);
+
+CREATE INDEX config_copy_tag_type_owner_idx
+    ON config.copy_tag_type (owner);
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/040.schema.asset.sql b/Open-ILS/src/sql/Pg/040.schema.asset.sql
index 283b476..267b302 100644
--- a/Open-ILS/src/sql/Pg/040.schema.asset.sql
+++ b/Open-ILS/src/sql/Pg/040.schema.asset.sql
@@ -905,5 +905,60 @@ BEGIN
 END;
 $F$ LANGUAGE PLPGSQL;
 
+CREATE TABLE asset.copy_tag (
+    id              SERIAL PRIMARY KEY,
+    tag_type        TEXT REFERENCES config.copy_tag_type (code)
+                    ON UPDATE CASCADE ON DELETE CASCADE,
+    label           TEXT NOT NULL,
+    value           TEXT NOT NULL,
+    index_vector    tsvector NOT NULL,
+    staff_note      TEXT,
+    pub             BOOLEAN DEFAULT TRUE,
+    owner           INTEGER NOT NULL REFERENCES actor.org_unit (id)
+);
+
+CREATE INDEX asset_copy_tag_label_idx
+    ON asset.copy_tag (label);
+CREATE INDEX asset_copy_tag_label_lower_idx
+    ON asset.copy_tag (evergreen.lowercase(label));
+CREATE INDEX asset_copy_tag_index_vector_idx
+    ON asset.copy_tag
+    USING GIN(index_vector);
+CREATE INDEX asset_copy_tag_tag_type_idx
+    ON asset.copy_tag (tag_type);
+CREATE INDEX asset_copy_tag_owner_idx
+    ON asset.copy_tag (owner);
+
+CREATE OR REPLACE FUNCTION asset.set_copy_tag_value () RETURNS TRIGGER AS $$
+BEGIN
+    IF NEW.value IS NULL THEN
+        NEW.value = NEW.label;        
+    END IF;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE 'plpgsql';
+
+-- name of following trigger chosen to ensure it runs first
+CREATE TRIGGER asset_copy_tag_do_value
+    BEFORE INSERT OR UPDATE ON asset.copy_tag
+    FOR EACH ROW EXECUTE PROCEDURE asset.set_copy_tag_value();
+CREATE TRIGGER asset_copy_tag_fti_trigger
+    BEFORE UPDATE OR INSERT ON asset.copy_tag
+    FOR EACH ROW EXECUTE PROCEDURE oils_tsearch2('default');
+
+CREATE TABLE asset.copy_tag_copy_map (
+    id              BIGSERIAL PRIMARY KEY,
+    copy            BIGINT REFERENCES asset.copy (id)
+                    ON UPDATE CASCADE ON DELETE CASCADE,
+    tag             INTEGER REFERENCES asset.copy_tag (id)
+                    ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE INDEX asset_copy_tag_copy_map_copy_idx
+    ON asset.copy_tag_copy_map (copy);
+CREATE INDEX asset_copy_tag_copy_map_tag_idx
+    ON asset.copy_tag_copy_map (tag);
+
 COMMIT;
 
diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql
index ed2e79f..7d10125 100644
--- a/Open-ILS/src/sql/Pg/800.fkeys.sql
+++ b/Open-ILS/src/sql/Pg/800.fkeys.sql
@@ -175,4 +175,6 @@ ALTER TABLE asset.copy_template ADD CONSTRAINT asset_copy_template_floating_fkey
 ALTER TABLE config.marc_field ADD CONSTRAINT config_marc_field_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 ALTER TABLE config.marc_subfield ADD CONSTRAINT config_marc_subfield_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 
+ALTER TABLE config.copy_tag_type ADD CONSTRAINT copy_tag_type_owner_fkey FOREIGN KEY (owner) REFERENCES  actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index ffa6732..eef768d 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -1675,7 +1675,11 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 588, 'ITEM_NOT_HOLDABLE.override', oils_i18n_gettext( 588,
     'Override the ITEM_NOT_HOLDABLE event', 'ppl', 'description' )),
  ( 589, 'ITEM_RENTAL_FEE_REQUIRED.override', oils_i18n_gettext( 589,
-    'Override the ITEM_RENTAL_FEE_REQUIRED event', 'ppl', 'description' ))
+    'Override the ITEM_RENTAL_FEE_REQUIRED event', 'ppl', 'description' )),
+ ( 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' ))
 ;
 
 SELECT SETVAL('permission.perm_list_id_seq'::TEXT, 1000);
@@ -16876,3 +16880,25 @@ INSERT into config.org_unit_setting_type (
     )
     ,'string'
 );
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype)
+VALUES (
+    'opac.search.enable_bookplate_search',
+    oils_i18n_gettext(
+        'opac.search.enable_bookplate_search',
+        'Enable Digital Bookplate Search',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'opac.search.enable_bookplate_search',
+        'If enabled, adds a "Digital Bookplate" option to the query type selectors in the public catalog for search on copy tags.',   
+        'coust',
+        'description'
+    ),
+    'opac',
+    'bool'
+);
+
+INSERT INTO config.copy_tag_type (code, label, owner) VALUES ('bookplate', 'Digital Bookplate', 1);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql
new file mode 100644
index 0000000..07cabb9
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_tags.sql
@@ -0,0 +1,96 @@
+BEGIN;
+
+CREATE TABLE config.copy_tag_type (
+    code            TEXT NOT NULL PRIMARY KEY,
+    label           TEXT NOT NULL,
+    owner           INTEGER NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED
+);
+
+CREATE INDEX config_copy_tag_type_owner_idx
+    ON config.copy_tag_type (owner);
+
+CREATE TABLE asset.copy_tag (
+    id              SERIAL PRIMARY KEY,
+    tag_type        TEXT REFERENCES config.copy_tag_type (code)
+                    ON UPDATE CASCADE ON DELETE CASCADE,
+    label           TEXT NOT NULL,
+    value           TEXT NOT NULL,
+    index_vector    tsvector NOT NULL,
+    staff_note      TEXT,
+    pub             BOOLEAN DEFAULT TRUE,
+    owner           INTEGER NOT NULL REFERENCES actor.org_unit (id)
+);
+
+CREATE INDEX asset_copy_tag_label_idx
+    ON asset.copy_tag (label);
+CREATE INDEX asset_copy_tag_label_lower_idx
+    ON asset.copy_tag (evergreen.lowercase(label));
+CREATE INDEX asset_copy_tag_index_vector_idx
+    ON asset.copy_tag
+    USING GIN(index_vector);
+CREATE INDEX asset_copy_tag_tag_type_idx
+    ON asset.copy_tag (tag_type);
+CREATE INDEX asset_copy_tag_owner_idx
+    ON asset.copy_tag (owner);
+
+CREATE OR REPLACE FUNCTION asset.set_copy_tag_value () RETURNS TRIGGER AS $$
+BEGIN
+    IF NEW.value IS NULL THEN
+        NEW.value = NEW.label;        
+    END IF;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE 'plpgsql';
+
+-- name of following trigger chosen to ensure it runs first
+CREATE TRIGGER asset_copy_tag_do_value
+    BEFORE INSERT OR UPDATE ON asset.copy_tag
+    FOR EACH ROW EXECUTE PROCEDURE asset.set_copy_tag_value();
+CREATE TRIGGER asset_copy_tag_fti_trigger
+    BEFORE UPDATE OR INSERT ON asset.copy_tag
+    FOR EACH ROW EXECUTE PROCEDURE oils_tsearch2('default');
+
+CREATE TABLE asset.copy_tag_copy_map (
+    id              BIGSERIAL PRIMARY KEY,
+    copy            BIGINT REFERENCES asset.copy (id)
+                    ON UPDATE CASCADE ON DELETE CASCADE,
+    tag             INTEGER REFERENCES asset.copy_tag (id)
+                    ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE INDEX asset_copy_tag_copy_map_copy_idx
+    ON asset.copy_tag_copy_map (copy);
+CREATE INDEX asset_copy_tag_copy_map_tag_idx
+    ON asset.copy_tag_copy_map (tag);
+
+INSERT INTO config.copy_tag_type (code, label, owner) VALUES ('bookplate', 'Digital Bookplate', 1);
+
+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' ))
+;
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype)
+VALUES (
+    'opac.search.enable_bookplate_search',
+    oils_i18n_gettext(
+        'opac.search.enable_bookplate_search',
+        'Enable Digital Bookplate Search',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'opac.search.enable_bookplate_search',
+        'If enabled, adds a "Digital Bookplate" option to the query type selectors in the public catalog for search on copy tags.',   
+        'coust',
+        'description'
+    ),
+    'opac',
+    'bool'
+);
+
+COMMIT;

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/fm_IDL.xml                       |   68 +++++++++++
 .../lib/OpenILS/Application/Cat/AssetCommon.pm     |   51 ++++++++
 .../src/perlmods/lib/OpenILS/Application/Circ.pm   |   68 +++++++++++
 .../Application/Storage/Driver/Pg/QueryParser.pm   |   49 ++++++++
 .../perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm |   14 ++
 .../perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm |   22 +++-
 Open-ILS/src/perlmods/t/21-QueryParser.t           |    3 +-
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   11 ++-
 Open-ILS/src/sql/Pg/040.schema.asset.sql           |   55 +++++++++
 Open-ILS/src/sql/Pg/800.fkeys.sql                  |    2 +
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |   28 ++++-
 Open-ILS/src/sql/Pg/t/copy_tags.pg                 |   55 +++++++++
 .../src/sql/Pg/upgrade/1047.schema.copy_tags.sql   |   98 +++++++++++++++
 Open-ILS/src/templates/opac/css/style.css.tt2      |    9 ++
 .../src/templates/opac/parts/qtype_selector.tt2    |    8 +-
 .../src/templates/opac/parts/record/copy_table.tt2 |   11 ++
 .../templates/staff/admin/local/asset/copy_tag.tt2 |   50 ++++++++
 .../src/templates/staff/admin/local/t_splash.tt2   |    1 +
 .../staff/admin/server/config/copy_tag_type.tt2    |   38 ++++++
 .../src/templates/staff/admin/server/t_splash.tt2  |    1 +
 .../staff/cat/bucket/copy/t_apply_tags.tt2         |   39 ++++++
 .../src/templates/staff/cat/bucket/copy/t_view.tt2 |    2 +
 .../templates/staff/cat/volcopy/t_attr_edit.tt2    |    9 ++
 .../templates/staff/cat/volcopy/t_copy_tags.tt2    |   39 ++++++
 .../src/templates/staff/cat/volcopy/t_defaults.tt2 |    6 +
 .../templates/staff/share/t_fm_record_editor.tt2   |    8 +-
 .../ui/default/staff/admin/local/asset/copy_tag.js |   90 ++++++++++++++
 .../staff/admin/server/config/copy_tag_type.js     |   73 +++++++++++
 .../web/js/ui/default/staff/cat/bucket/copy/app.js |   78 ++++++++++++
 .../web/js/ui/default/staff/cat/volcopy/app.js     |  126 +++++++++++++++++++-
 .../ui/default/staff/services/fm_record_editor.js  |    4 +
 docs/RELEASE_NOTES_NEXT/Cataloging/Copy_tags.adoc  |   51 ++++++++
 32 files changed, 1158 insertions(+), 9 deletions(-)
 create mode 100644 Open-ILS/src/sql/Pg/t/copy_tags.pg
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1047.schema.copy_tags.sql
 create mode 100644 Open-ILS/src/templates/staff/admin/local/asset/copy_tag.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/server/config/copy_tag_type.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/copy/t_apply_tags.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/volcopy/t_copy_tags.tt2
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/local/asset/copy_tag.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/server/config/copy_tag_type.js
 create mode 100644 docs/RELEASE_NOTES_NEXT/Cataloging/Copy_tags.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list