LP1965446 Option to Disable Title-Level Holds on Bib Records with Parts
authorJason Etheridge <jason@EquinoxOLI.org>
Thu, 12 Jan 2023 06:16:29 +0000 (01:16 -0500)
committerMichele Morgan <mmorgan@noblenet.org>
Wed, 3 May 2023 18:38:57 +0000 (14:38 -0400)
This feature adds one global flag and one library setting, respectively:

    * circ.holds.api_require_monographic_part_when_present
      Holds: Require Monographic Part When Present for hold check.
    * circ.holds.ui_require_monographic_part_when_present
      Require Monographic Part when Present

Normally the selection of a monographic part during hold placement is optional if there is at least one copy
on the bib without a monographic part.  A true value for this setting for any involved owning library for the
bib or for the global flag will require part selection even under this condition.  This essentially removes
the All/Any Parts option from the part selection drop-down, for both versions of the public catalog (TPAC and
BOOPAC), and for the Angular staff catalog interface.

At the API level, we consider just the global flag and will throw a TITLE_HOLD_WHEN_MONOGRAPHIC_PART_REQUIRED
event for a title hold request when there are items with monographic parts on the bib.  It is possible for
the library settings and the global flag to differ, but the global flag will catch every instance of hold
placement including those by third party callers, SIP, etc.

Signed-off-by: Jason Etheridge <jason@EquinoxOLI.org>
Signed-off-by: Ruth Frasur <rfrasur@library.in.gov>
Signed-off-by: Jennifer Weston <jennifer.weston@equinoxoli.org>
Signed-off-by: Michele Morgan <mmorgan@noblenet.org>

12 files changed:
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.eparts.sql [new file with mode: 0644]
Open-ILS/src/templates-bootstrap/opac/parts/hold_error_messages.tt2
Open-ILS/src/templates/opac/parts/hold_error_messages.tt2
Open-ILS/web/js/dojo/openils/circ/nls/selfcheck.js
docs/RELEASE_NOTES_NEXT/miscellaneous.adoc

index a367287..ce18269 100644 (file)
         <div class="col-lg-2">
           <ng-container *ngIf="ctx.holdMeta.parts.length">
             <select class="form-select"  (change)="setPart(ctx, $event)"
-              [ngModel]="ctx.holdMeta.part ? ctx.holdMeta.part.id() : ''">
-              <option value="" i18n>Any Part</option>
+              [ngModel]="ctx.holdMeta.part ? ctx.holdMeta.part.id() : (ctx.holdMeta.part_required ? ctx.holdMeta.parts[0].id() : '')">
+              <option *ngIf="!ctx.holdMeta.part_required" value="" i18n>Any Part</option>
               <option *ngFor="let part of ctx.holdMeta.parts"
                 value="{{part.id()}}">{{part.label()}}</option>
             </select>
index a74bc37..fc4c836 100644 (file)
@@ -245,7 +245,7 @@ export class HoldComponent implements OnInit {
     getTargetMeta(): Promise<any> {
 
         return new Promise(resolve => {
-            this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
+            this.holds.getHoldTargetMeta(this.holdType, this.holdTargets, this.auth.user().ws_ou())
             .subscribe(
                 meta => {
                     this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
@@ -612,6 +612,11 @@ export class HoldComponent implements OnInit {
 
         let hType = this.holdType;
         let hTarget = ctx.holdTarget;
+
+        if (ctx.holdMeta.parts && !ctx.holdMeta.part) {
+            ctx.holdMeta.part = (ctx.holdMeta.part_required ? ctx.holdMeta.parts[0] : null);
+        }
+
         if (hType === 'T' && ctx.holdMeta.part) {
             // A Title hold morphs into a Part hold at hold placement time
             // if a part is selected.  This can happen on a per-hold basis
index f1c6d0b..b5d5531 100644 (file)
@@ -55,6 +55,7 @@ export interface HoldRequestTarget {
     copy?: IdlObject;
     issuance?: IdlObject;
     metarecord_filters?: any;
+    part_required?: boolean;
 }
 
 /** Service for performing various hold-related actions */
index 6e9878b..d295fa2 100644 (file)
@@ -2877,6 +2877,46 @@ sub _check_title_hold_is_possible {
     # $holdable_formats is now unused. We pre-filter the MR's records.
 
     my $e = new_editor();
+
+    # T holds on records that have parts are normally OK, but if the record has
+    # no non-part copies, the hold will ultimately fail, so let's test for that.
+    #
+    # If the global flag circ.holds.api_require_monographic_part_when_present is
+    # enabled, then any configured parts for the bib is enough to disallow title holds.
+    my $part_required = 0;
+    my $parts = $e->search_biblio_monograph_part(
+        {
+            record => $titleid
+        }, {idlist=>1} );
+
+    if (@$parts) {
+        my $part_required_flag = $e->retrieve_config_global_flag('circ.holds.api_require_monographic_part_when_present');
+        $part_required = ($part_required_flag and $U->is_true($part_required_flag->enabled));
+        if (!$part_required) {
+            my $np_copies = $e->json_query({
+                select => { acp => [{column => 'id', transform => 'count', alias => 'count'}]},
+                from => {acp => {acn => {}, acpm => {type => 'left'}}},
+                where => {
+                    '+acp' => {deleted => 'f'},
+                    '+acn' => {deleted => 'f', record => $titleid},
+                    '+acpm' => {id => undef}
+                }
+            });
+            $part_required = 1 if $np_copies->[0]->{count} == 0;
+        }
+    }
+    if ($part_required) {
+        $logger->info("title hold when monographic part required");
+        return (
+            0, 0, [
+                new OpenILS::Event(
+                    "TITLE_HOLD_WHEN_MONOGRAPHIC_PART_REQUIRED",
+                    "payload" => {"fail_part" => "monographic_part_required"}
+                )
+            ]
+        );
+    }
+
     my %org_filter = create_ranged_org_filter($e, $selection_ou, $depth);
 
     # this monster will grab the id and circ_lib of all of the "holdable" copies for the given record
@@ -5153,6 +5193,7 @@ sub hold_metadata {
             issuance => $issuance,
             part => $part,
             parts => [],
+            part_required => 'f',
             bibrecord => $bre,
             metarecord => $metarecord,
             metarecord_filters => {}
@@ -5178,6 +5219,56 @@ sub hold_metadata {
                     {order_by => {bmp => 'label_sortkey'}}
                 ]
             );
+
+            # T holds on records that have parts are normally OK, but if the record has
+            # no non-part copies, the hold will ultimately fail.  When that happens,
+            # require the user to select a part.
+            #
+            # If the global flag circ.holds.api_require_monographic_part_when_present is
+            # enabled, or the library setting circ.holds.ui_require_monographic_part_when_present
+            # is true for any involved owning_library, then also require part selection.
+            my $part_required = 0;
+            if ($meta->{parts}) {
+                my $part_required_flag = $e->retrieve_config_global_flag('circ.holds.api_require_monographic_part_when_present');
+                $part_required = ($part_required_flag and $U->is_true($part_required_flag->enabled));
+                if (!$part_required) {
+                    my $resp = $e->json_query({
+                        select => {
+                            acn => ['owning_lib']
+                        },
+                        from => {acn => {acp => {type => 'left'}}},
+                        where => {
+                            '+acp' => {
+                                '-or' => [
+                                    {deleted => 'f'},
+                                    {id => undef} # left join
+                                ]
+                            },
+                            '+acn' => {deleted => 'f', record => $bre->id}
+                        },
+                        distinct => 't'
+                    });
+                    my $org_ids = [map {$_->{owning_lib}} @$resp];
+                    foreach my $org (@$org_ids) { # FIXME: worth shortcutting/optimizing?
+                        if ($U->ou_ancestor_setting_value($org, 'circ.holds.ui_require_monographic_part_when_present')) {
+                            $part_required = 1;
+                        }
+                    }
+                }
+                if (!$part_required) {
+                    my $np_copies = $e->json_query({
+                        select => { acp => [{column => 'id', transform => 'count', alias => 'count'}]},
+                        from => {acp => {acn => {}, acpm => {type => 'left'}}},
+                        where => {
+                            '+acp' => {deleted => 'f'},
+                            '+acn' => {deleted => 'f', record => $bre->id},
+                            '+acpm' => {id => undef}
+                        }
+                    });
+                    $part_required = 1 if $np_copies->[0]->{count} == 0;
+                }
+            }
+            $meta->{part_required} = $part_required;
         }
 
         if ($meta->{metarecord}) {
index fab9dd6..f5c59a9 100644 (file)
@@ -374,6 +374,9 @@ sub load_common {
     my $geo_org = $ctx->{physical_loc} || $self->cgi->param('loc') || $ctx->{aou_tree}->()->id;
     my $geo_sort_for_org = $ctx->{get_org_setting}->($geo_org, 'opac.holdings_sort_by_geographic_proximity');
     $ctx->{geo_sort} = $geo_sort && $U->is_true($geo_sort_for_org);
+    my $part_required_flag = $e->retrieve_config_global_flag('circ.holds.api_require_monographic_part_when_present');
+    $part_required_flag = ($part_required_flag and $U->is_true($part_required_flag->enabled));
+    $ctx->{part_required_when_present_global_flag} = $part_required_flag;
 
     # capture some commonly accessed pages
     $ctx->{home_page} = $ctx->{proto} . '://' . $ctx->{hostname} . $self->ctx->{opac_root} . "/home";
index 6dacc2d..a98d789 100644 (file)
@@ -1622,21 +1622,53 @@ sub load_place_hold {
                     {record => $rec->id}
                 );
 
-                # T holds on records that have parts are OK, but if the record has
-                # no non-part copies, the hold will ultimately fail.  When that
-                # happens, require the user to select a part.
+                # T holds on records that have parts are normally OK, but if the record has
+                # no non-part copies, the hold will ultimately fail.  When that happens,
+                # require the user to select a part.
+                #
+                # If the global flag circ.holds.api_require_monographic_part_when_present is
+                # enabled, or the library setting circ.holds.ui_require_monographic_part_when_present
+                # is active for any item owning library associated with the bib, then any configured
+                # parts for the bib is enough to disallow title holds (aka require part selection).
                 my $part_required = 0;
                 if (@$parts) {
-                    my $np_copies = $e->json_query({
-                        select => { acp => [{column => 'id', transform => 'count', alias => 'count'}]},
-                        from => {acp => {acn => {}, acpm => {type => 'left'}}},
-                        where => {
-                            '+acp' => {deleted => 'f'},
-                            '+acn' => {deleted => 'f', record => $rec->id},
-                            '+acpm' => {id => undef}
+                    $part_required = $ctx->{part_required_when_present_global_flag};
+                    if (!$part_required) {
+                        my $resp = $e->json_query({
+                            select => {
+                                acn => ['owning_lib']
+                            },
+                            from => {acn => {acp => {type => 'left'}}},
+                            where => {
+                                '+acp' => {
+                                    '-or' => [
+                                        {deleted => 'f'},
+                                        {id => undef} # left join
+                                    ]
+                                },
+                                '+acn' => {deleted => 'f', record => $rec->id}
+                            },
+                            distinct => 't'
+                        });
+                        my $org_ids = [map {$_->{owning_lib}} @$resp];
+                        foreach my $org (@$org_ids) { # FIXME: worth shortcutting/optimizing?
+                            if ($self->ctx->{get_org_setting}->($org, 'circ.holds.ui_require_monographic_part_when_present')) {
+                                $part_required = 1;
+                            }
                         }
-                    });
-                    $part_required = 1 if $np_copies->[0]->{count} == 0;
+                    }
+                    if (!$part_required) {
+                        my $np_copies = $e->json_query({
+                            select => { acp => [{column => 'id', transform => 'count', alias => 'count'}]},
+                            from => {acp => {acn => {}, acpm => {type => 'left'}}},
+                            where => {
+                                '+acp' => {deleted => 'f'},
+                                '+acn' => {deleted => 'f', record => $rec->id},
+                                '+acpm' => {id => undef}
+                            }
+                        });
+                        $part_required = 1 if $np_copies->[0]->{count} == 0;
+                    }
                 }
 
                 push(@hold_data, $data_filler->({
index 2867f6f..3d123ad 100644 (file)
@@ -21608,6 +21608,33 @@ VALUES (
     'integer'
 );
 
+-- eparts
+
+INSERT INTO config.global_flag (name, value, enabled, label)
+VALUES (
+    'circ.holds.api_require_monographic_part_when_present',
+    NULL,
+    FALSE,
+    oils_i18n_gettext(
+        'circ.holds.api_require_monographic_part_when_present',
+        'Holds: Require Monographic Part When Present for hold check.',
+        'cgf', 'label'
+    )
+);
+
+INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype)
+VALUES (
+    'circ.holds.ui_require_monographic_part_when_present',
+    oils_i18n_gettext('circ.holds.ui_require_monographic_part_when_present',
+        'Require Monographic Part when Present',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.holds.ui_require_monographic_part_when_present',
+        'Normally the selection of a monographic part during hold placement is optional if there is at least one copy on the bib without a monographic part.  A true value for this setting will require part selection even under this condition.',
+        'coust', 'description'),
+    'bool'
+);
+
 ------------------- Disabled example A/T defintions ------------------------------
 
 -- Create a "dummy" slot when applicable, and trigger the "offer curbside" events
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.eparts.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.eparts.sql
new file mode 100644 (file)
index 0000000..f384372
--- /dev/null
@@ -0,0 +1,33 @@
+BEGIN;
+
+-- check whether patch can be applied
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+-- 950.data.seed-values.sql
+
+INSERT INTO config.global_flag (name, value, enabled, label)
+VALUES (
+    'circ.holds.api_require_monographic_part_when_present',
+    NULL,
+    FALSE,
+    oils_i18n_gettext(
+        'circ.holds.api_require_monographic_part_when_present',
+        'Holds: Require Monographic Part When Present for hold check.',
+        'cgf', 'label'
+    )
+);
+
+INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype)
+VALUES (
+    'circ.holds.ui_require_monographic_part_when_present',
+    oils_i18n_gettext('circ.holds.ui_require_monographic_part_when_present',
+        'Require Monographic Part when Present',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.holds.ui_require_monographic_part_when_present',
+        'Normally the selection of a monographic part during hold placement is optional if there is at least one copy on the bib without a monographic part.  A true value for this setting will require part selection even under this condition.',
+        'coust', 'description'),
+    'bool'
+);
+
+COMMIT;
index 407ea52..f36a0ac 100755 (executable)
@@ -23,6 +23,7 @@
         "status.holdable" => l("The item is not in a holdable status"),
         "no_item" => l("The system could not find this item"),
         "no_ultimate_items" => l("The system could not find any items to match this hold request"),
+        "monographic_part_required" => l("Title hold request invalid when monographic part required"),
         "no_matchpoint" => l("System rules do not define how to handle this item"),
         "no_user" => l("The system could not find this patron"),
         "transit_range" => l("The item cannot transit this far")
index 407ea52..f36a0ac 100644 (file)
@@ -23,6 +23,7 @@
         "status.holdable" => l("The item is not in a holdable status"),
         "no_item" => l("The system could not find this item"),
         "no_ultimate_items" => l("The system could not find any items to match this hold request"),
+        "monographic_part_required" => l("Title hold request invalid when monographic part required"),
         "no_matchpoint" => l("System rules do not define how to handle this item"),
         "no_user" => l("The system could not find this patron"),
         "transit_range" => l("The item cannot transit this far")
index 438d556..e9e8e60 100644 (file)
@@ -41,6 +41,7 @@
     "FAIL_PART_config_rule_age_hold_protect_prox": "The item is too new to transit this far",
     "FAIL_PART_no_item": "The system could not find this item",
     "FAIL_PART_no_ultimate_items": "The system could not find any items to match this hold request",
+    "FAIL_PART_monographic_part_required": "Title hold request invalid when monographic part required",
     "FAIL_PART_no_matchpoint": "System rules do not define how to handle this item",
     "FAIL_PART_no_user": "The system could not find this patron",
     "FAIL_PART_transit_range": "The item cannot transit this far",
index a17df9a..dc24505 100644 (file)
 * Adds new Local Administration entries for Item Statistical Categories Editor and Patron Statistical Categories Editor, which are angularized interfaces.
 * Tweaks eg-grids to underline hyperlinks within cells.  This potentially affects multiple interfaces.
 * eg-org-family-select now supports persistKey
+* LP1965446 Option to Disable Title-Level Holds on Bib Records with Parts
+
+    This feature adds one global flag and one library setting, respectively:
+
+        * circ.holds.api_require_monographic_part_when_present
+          Holds: Require Monographic Part When Present for hold check.
+        * circ.holds.ui_require_monographic_part_when_present
+          Require Monographic Part when Present
+
+    Normally the selection of a monographic part during hold placement is optional if there is at least one copy
+    on the bib without a monographic part.  A true value for this setting for any involved owning library for the
+    bib or for the global flag will require part selection even under this condition.  This essentially removes
+    the All/Any Parts option from the part selection drop-down, for both versions of the public catalog (TPAC and
+    BOOPAC), and for the Angular staff catalog interface. It should be noted that if the library setting is set
+    below the consortium level, Title level holds may be allowed for some libraries and not others.
+
+    At the API level, we consider just the global flag and will throw a TITLE_HOLD_WHEN_MONOGRAPHIC_PART_REQUIRED
+    event for a title hold request when there are items with monographic parts on the bib.  It is possible for
+    the library settings and the global flag to differ, but the global flag will catch every instance of hold
+    placement including those by third party callers, SIP, etc.