diff --git a/app/controllers/alma_controller.rb b/app/controllers/alma_controller.rb index ba81d555..7ce551fb 100644 --- a/app/controllers/alma_controller.rb +++ b/app/controllers/alma_controller.rb @@ -4,7 +4,9 @@ class AlmaController < ApplicationController def sru return unless AlmaSru.enabled? && expected_params? - @availability = AlmaSru.lookup(params[:doc_id]) + result = AlmaSru.lookup(params[:doc_id]) + @availability = result[:availability] + @alma_e = result[:alma_e] end private diff --git a/app/javascript/controllers/content_loader_controller.js b/app/javascript/controllers/content_loader_controller.js index 02450a68..e3af9f05 100644 --- a/app/javascript/controllers/content_loader_controller.js +++ b/app/javascript/controllers/content_loader_controller.js @@ -33,6 +33,16 @@ export default class extends Controller { // Replace the entire element with the fetched HTML, or remove if empty if (html.trim()) { this.element.outerHTML = html + + // Keep Alma availability in `.result-content` but place “Full-text options” + // with fulfillment links in the descendant `.result-get` container. + const resultContent = parentElement.closest('.result-content') || parentElement + const almaFulltextOptions = resultContent.querySelector('.alma-fulltext-options') + const resultGet = resultContent.querySelector('.result-get') + if (almaFulltextOptions && resultGet) { + resultGet.appendChild(almaFulltextOptions) + } + // Hide primo links if libkey link is present if (parentElement.querySelector('.libkey-link')) { const resultGet = parentElement.closest('.result-get') diff --git a/app/models/alma_sru.rb b/app/models/alma_sru.rb index 7777928a..fa570aa0 100644 --- a/app/models/alma_sru.rb +++ b/app/models/alma_sru.rb @@ -20,11 +20,15 @@ class InvalidAlmaId < StandardError; end # lookup is the primary method of interacting with this model. # - # It will receive an Alma ID, validate it, look it up in the Alma SRU, and return a formatted result. + # It will receive an Alma ID, validate it, look it up in the Alma SRU, and return availability info and Alma-E status. # # It accepts an "alma_client" argument for use when testing, but this is not used in normal operations. + # + # Returns a hash with: + # - :availability => formatted availability statements array + # - :alma_e => boolean indicating if record is Alma-E def self.lookup(raw_identifier, alma_client: nil) - return [] unless enabled? + return { availability: [], alma_e: false } unless enabled? # Validate the raw identifier received. This will raise an InvalidAlmaId if validation fails. raise InvalidAlmaId unless valid_alma_id?(raw_identifier) @@ -41,24 +45,22 @@ def self.lookup(raw_identifier, alma_client: nil) parse_response(alma_http.timeout(6).get(url), identifier) rescue InvalidAlmaId Rails.logger.debug("Invalid Alma ID: #{raw_identifier}") - - [] + { availability: [], alma_e: false } rescue LookupFailure => e Rails.logger.debug("Alma lookup failure: #{e}") - - [] + { availability: [], alma_e: false } rescue HTTP::Error Sentry.capture_message('Alma SRU connection failure') Rails.logger.error('Alma SRU connection error') - - [] + { availability: [], alma_e: false } end # parse_response receives the raw response from the Alma SRU endpoint. # # For any non-200 response, it raises a LookupFailure. # - # Other responses (in XML format) are parsed by Nokogiri, and we pluck content with an `AVA` tag. + # Other responses (in XML format) are parsed by Nokogiri to extract both AVA (holdings) and AVE (electronic) data. + # Returns a hash with :availability and :alma_e keys. def self.parse_response(raw_response, reference_identifier) raise LookupFailure, raw_response.status unless raw_response.status == 200 @@ -76,7 +78,15 @@ def self.parse_response(raw_response, reference_identifier) # Reduce list to a single item if multiples exist results[0] += ' and other locations' if results.length > 1 - results.first(1) + availability = results.first(1) + + # Check for AVE tags to determine if record is Alma-E + alma_e = alma_e?(parsed) + + { + availability: availability, + alma_e: alma_e + } end # ava_to_hash takes an XML element that represents a single availability record @@ -112,6 +122,26 @@ def self.fetch_controlfield(parsed_xml) parsed_xml.xpath("//holding:controlfield[@tag='001']", NAMESPACE)&.text end + # alma_e? receives a parsed XML document (Nokogiri::XML::Document) and returns true if the record + # contains AVE (electronic) datafields. We fall back on the 959 subfield b for legacy records. + def self.alma_e?(parsed_xml) + return true if parsed_xml.xpath("//holding:datafield[@tag='AVE']", NAMESPACE).any? + + legacy_net_access?(parsed_xml) + end + + # Some records omit AVE but still indicate electronic access in local 959$b=NET. After consulting + # with Metadata and Enterprise Systems, we learned that this a deprecated practice from before the + # Alma migration. + # + # It is still unclear whether Primo is determining electronic access from this subfield, but it's + # the only electronic access indicator we can find in the record other than AVE. We can revisit + # this approach if it proves ineffective. + def self.legacy_net_access?(parsed_xml) + parsed_xml.xpath("//holding:datafield[@tag='959']/holding:subfield[@code='b']", NAMESPACE) + .any? { |node| node.text.to_s.strip.casecmp('NET').zero? } + end + # format_availability receives a hash representing a single availability # statement, and formats it for human readability. Values for "e" and "q" are # required, while "c" and "d" are optional. diff --git a/app/models/normalize_primo_record.rb b/app/models/normalize_primo_record.rb index 8c7a6cc5..dcfe7d52 100644 --- a/app/models/normalize_primo_record.rb +++ b/app/models/normalize_primo_record.rb @@ -131,14 +131,6 @@ def links end end - # Add Full-text options if pnx['links'] is nil and record has Alma-E (electronic availability) - full_record_link = record_link - if @record.dig('pnx', 'links').nil? && - @record.dig('delivery', 'deliveryCategory')&.include?('Alma-E') && - full_record_link.present? - links << { 'url' => "#{full_record_link}#nui.getit.service_viewit", 'kind' => 'Full-text options' } - end - # Return links if we found any links.any? ? links : [] end diff --git a/app/views/alma/sru.html.erb b/app/views/alma/sru.html.erb index 10c50e5c..4da03e48 100644 --- a/app/views/alma/sru.html.erb +++ b/app/views/alma/sru.html.erb @@ -1,9 +1,15 @@ -<% if AlmaSru.enabled? && @availability.present? %> -
- <% @availability.each do |statement| %> -

<%= link_to(sanitize(statement, tags: %w[i strong], attributes: %w[class aria-hidden]), - "#{PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link}#getit_link1_0", - data: {content_piece: 'Availability Link' }) %>

- <% end %> -
+<% if AlmaSru.enabled? %> + <% if @alma_e %> + <% fulltext_url = PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link + '#nui.getit.service_viewit' %> + <%= link_to 'Full-text options', fulltext_url, class: 'button alma-fulltext-options', data: { content_piece: 'Full-text Options' } %> + <% end %> + <% if @availability.present? %> +
+ <% @availability.each do |statement| %> +

<%= link_to(sanitize(statement, tags: %w[i strong], attributes: %w[class aria-hidden]), + "#{PrimoLinkBuilder.new(record_id: params[:doc_id], context: 'L').full_record_link}#getit_link1_0", + data: {content_piece: 'Availability Link' }) %>

+ <% end %> +
+ <% end %> <% end %> diff --git a/app/views/search/_result_primo.html.erb b/app/views/search/_result_primo.html.erb index 7b8d47ac..efc0f7d8 100644 --- a/app/views/search/_result_primo.html.erb +++ b/app/views/search/_result_primo.html.erb @@ -78,11 +78,13 @@ <% end %> - <% if AlmaSru.enabled? && AlmaSru.valid_alma_id?(result[:identifier]) %> + <% should_render_almasru = AlmaSru.enabled? && AlmaSru.valid_alma_id?(result[:identifier]) %> + + <% if should_render_almasru %> <%= render(partial: 'trigger_almasru', locals: { doc_id: result[:identifier] }) %> <% end %> - <% if result_get?(result) %> + <% if result_get?(result) || should_render_almasru %>
<% if result[:links].present? %> <% result[:links].each do |link| %> diff --git a/test/controllers/alma_controller_test.rb b/test/controllers/alma_controller_test.rb index b6d9c00c..5f1d8da6 100644 --- a/test/controllers/alma_controller_test.rb +++ b/test/controllers/alma_controller_test.rb @@ -16,17 +16,27 @@ class AlmaControllerTest < ActionDispatch::IntegrationTest assert response.body.blank? end - test 'alma sru route returns nothing if lookup returns content with no AVA' do + test 'alma sru route returns full-text options when Alma-E is true' do VCR.use_cassette('alma sru no availability') do needle = 'alma9935053423706761' get almasru_path(doc_id: needle) + assert_response :success + assert_select 'a.button', { count: 1, text: 'Full-text options' } + end + end + + test 'alma sru route returns nothing when lookup has no AVA and no Alma-E' do + VCR.use_cassette('alma sru nonexistent record') do + needle = 'alma9900000000006761' + get almasru_path(doc_id: needle) + assert_response :success assert response.body.blank? end end - test 'alma sru route returns HTML for successful lookup' do + test 'alma sru route returns availability for successful lookup' do VCR.use_cassette('alma sru single record') do needle = 'alma990014651640106761' get almasru_path(doc_id: needle) diff --git a/test/models/alma_sru_test.rb b/test/models/alma_sru_test.rb index d2eec48a..cfbb65af 100644 --- a/test/models/alma_sru_test.rb +++ b/test/models/alma_sru_test.rb @@ -43,8 +43,9 @@ class AlmaSruTest < ActiveSupport::TestCase assert_equal( [" Available in Rotch Library Stacks (NA680.C25 2007)"], - result + result[:availability] ) + assert_equal false, result[:alma_e] end end @@ -54,79 +55,154 @@ class AlmaSruTest < ActiveSupport::TestCase result = AlmaSru.lookup(needle) - assert_equal(1, result.length) - assert_includes result[0], 'and other locations' + assert_equal(1, result[:availability].length) + assert_includes result[:availability][0], 'and other locations' + assert_equal false, result[:alma_e] end end - test 'lookup returns empty list if no availability' do + test 'lookup returns empty availability if no AVA' do VCR.use_cassette('alma sru no availability') do needle = 'alma9935053423706761' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal([], result[:availability]) end end + test 'lookup returns true Alma-E if AVE is present' do + # This cassette was generated to demonstrate a record with no AVA, but it has an AVE + VCR.use_cassette('alma sru no availability') do + needle = 'alma9935053423706761' + + result = AlmaSru.lookup(needle) + + assert_equal true, result[:alma_e] + end + end + + test 'lookup returns false Alma-E when AVE is absent' do + VCR.use_cassette('alma sru single record') do + needle = 'alma990014651640106761' + + result = AlmaSru.lookup(needle) + + assert_equal false, result[:alma_e] + end + end + + test 'alma_e? returns true when AVE is absent but 959 subfield b is NET' do + xml_content = <<~XML + + + + + + 990027661060106761 + + MIT Access Only + n-mit + NET + **See URL(s) + + + + + + + XML + + parsed = Nokogiri::XML(xml_content) + assert_equal true, AlmaSru.alma_e?(parsed) + end + + test 'alma_e? returns false when AVE is absent and 959 subfield b is not NET' do + xml_content = <<~XML + + + + + + 990002941700106761 + + LSA + JRNAL + + + + + + + XML + + parsed = Nokogiri::XML(xml_content) + assert_equal false, AlmaSru.alma_e?(parsed) + end + test 'lookup returns empty list for non-existent records' do VCR.use_cassette('alma sru nonexistent record') do needle = 'alma9900000000006761' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal([], result[:availability]) + assert_equal false, result[:alma_e] end end - test 'lookup returns empty list if alma URL not set' do + test 'lookup returns empty availability if alma URL not set' do needle = 'alma990014651640106761' VCR.use_cassette('alma sru single record') do - assert_equal(1, AlmaSru.lookup(needle).length) + result = AlmaSru.lookup(needle) + assert_equal(1, result[:availability].length) end ClimateControl.modify(MIT_ALMA_URL: nil) do - assert_equal([], AlmaSru.lookup(needle)) + result = AlmaSru.lookup(needle) + assert_equal({ availability: [], alma_e: false }, result) end end - test 'lookup returns empty list if exl_inst_id not set' do + test 'lookup returns empty availability if exl_inst_id not set' do needle = 'alma990014651640106761' VCR.use_cassette('alma sru single record') do - assert_equal(1, AlmaSru.lookup(needle).length) + result = AlmaSru.lookup(needle) + assert_equal(1, result[:availability].length) end ClimateControl.modify(EXL_INST_ID: nil) do AlmaSru.remove_instance_variable(:@enabled) - assert_equal([], AlmaSru.lookup(needle)) + result = AlmaSru.lookup(needle) + assert_equal({ availability: [], alma_e: false }, result) end end - test 'lookup returns empty list with non-complying ID' do + test 'lookup returns empty hash for non-complying ID' do needle = 'foo' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end - test 'lookup returns empty list with empty string' do + test 'lookup returns empty hash with empty string' do needle = '' result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end - test 'lookup returns empty list with nil input' do + test 'lookup returns empty hash with nil input' do needle = nil result = AlmaSru.lookup(needle) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end test 'lookup survives failing to connect to Alma SRU' do @@ -137,7 +213,7 @@ class AlmaSruTest < ActiveSupport::TestCase assert_nothing_raised do result = AlmaSru.lookup(needle, alma_client: alma_client) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end end @@ -149,7 +225,7 @@ class AlmaSruTest < ActiveSupport::TestCase assert_nothing_raised do result = AlmaSru.lookup(needle, alma_client: alma_client) - assert_equal([], result) + assert_equal({ availability: [], alma_e: false }, result) end end diff --git a/test/models/normalize_primo_record_test.rb b/test/models/normalize_primo_record_test.rb index 35d106e0..8b630bab 100644 --- a/test/models/normalize_primo_record_test.rb +++ b/test/models/normalize_primo_record_test.rb @@ -458,20 +458,6 @@ def cdi_record assert_not normalized[:dedup_record] end - test 'includes Full-text options link when pnx[links] is nil and both Alma-P and Alma-E present' do - record = alma_record.deep_dup - - # Ensure no direct links - record['pnx']['links'] = nil - - # Add delivery category with both physical and electronic - record['delivery']['deliveryCategory'] = %w[Alma-P Alma-E] - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_not_nil full_text_link - assert_match %r{/discovery/fulldisplay\?}, full_text_link['url'] - assert_match(/#nui\.getit\.service_viewit$/, full_text_link['url']) - end test 'excludes Full-text options link when pnx[links] is present' do record = full_record.deep_dup @@ -492,26 +478,6 @@ def cdi_record assert_nil full_text_link end - test 'includes Full-text options link when only Alma-E present' do - record = alma_record.deep_dup - record['pnx']['links'] = nil - record['delivery']['deliveryCategory'] = ['Alma-E'] - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_not_nil full_text_link - assert_match %r{/discovery/fulldisplay\?}, full_text_link['url'] - assert_match(/#nui\.getit\.service_viewit$/, full_text_link['url']) - end - - test 'excludes Full-text options link when no delivery category present' do - record = alma_record.deep_dup - record['pnx']['links'] = nil - record['delivery']['deliveryCategory'] = nil - normalized = NormalizePrimoRecord.new(record, 'test').normalize - full_text_link = normalized[:links].find { |link| link['kind'] == 'Full-text options' } - assert_nil full_text_link - end - test 'dedup_url requires both frbrized and alma_record conditions' do # CDI record that is frbrized - should return nil normalizer = NormalizePrimoRecord.new(cdi_record, 'test')