From 5b109f49e3a94c26c8efb7bb55965f20b2f7850b Mon Sep 17 00:00:00 2001 From: Lebbeous Fogle-Weekley Date: Mon, 2 Apr 2012 07:20:33 -0400 Subject: [PATCH] TPAC: Improvement to serials display (under the "issues held" label) (All the following text assumes you're using "new" 2.0+ serials and that the org unit setting 'opac.fully_compressed_serial_holdings' is true in the context where you're browsing.) Today on the TPAC record detail page under the "issues held" label, you get a list of serial summary statements, which you can expand to a list of issuances for which items have been received. You can place issuance-level holds on these. That existing interface just kind of burps up all your holdings within scope and doesn't show you what holdings belong to what org unit. Furthermore, it doesn't group your holdings into enumeration or chronology units, which can matter a lot if you've got 150 years of some daily newspaper and you're trying to browse through them in the OPAC. This new interface presents expanded serials holdings organized into a tree, with summaries placed under their org units and holdings grouped under their summaries under either chronology units (default) or enumeration ones, controlled by a new field on serial.distribution. There's also a new org unit setting that lets you change the default value for this new field in the Alternate Serial Control view to enumeration, if you want. Like the issues-held interface it's replacing, this knows how to deal with holdings where you have one unit per received item, or no units per received item, but its behavior is not yet defined for one unit per many items (the binding case). The "regular" Serial Control view doesn't have a widget to control the new field on serial.distribution yet. These are the areas where I'd be interested in helping to close the gaps, before or after this is committed. Signed-off-by: Lebbeous Fogle-Weekley Conflicts: Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm Open-ILS/web/css/skin/default/opac/style.css Signed-off-by: Dan Wells Signed-off-by: Mike Rylander --- Open-ILS/examples/fm_IDL.xml | 28 +- Open-ILS/src/extras/ils_events.xml | 5 + .../perlmods/lib/OpenILS/Application/AppUtils.pm | 130 ++++- .../src/perlmods/lib/OpenILS/Application/Serial.pm | 9 +- .../lib/OpenILS/Application/Serial/OPAC.pm | 633 +++++++++++++++++++++ .../src/perlmods/lib/OpenILS/Utils/MFHD/Holding.pm | 164 ++++-- .../perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm | 214 ++++--- .../perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm | 23 + Open-ILS/src/sql/Pg/002.schema.config.sql | 1 + Open-ILS/src/sql/Pg/020.schema.functions.sql | 29 + Open-ILS/src/sql/Pg/210.schema.serials.sql | 89 +++ Open-ILS/src/sql/Pg/950.data.seed-values.sql | 16 +- .../upgrade/XXXX.schema.serial-holding-groups.sql | 155 +++++ Open-ILS/src/templates/opac/parts/header.tt2 | 20 +- .../src/templates/opac/parts/record/copy_table.tt2 | 64 ++- .../src/templates/opac/parts/record/issues-db.tt2 | 161 ++++++ .../templates/opac/parts/record/issues-mfhd.tt2 | 40 ++ .../src/templates/opac/parts/record/issues.tt2 | 67 +-- .../src/templates/opac/parts/record/summary.tt2 | 2 +- Open-ILS/src/templates/serial/subscription.tt2 | 34 ++ Open-ILS/web/css/skin/default/opac/style.css | 3 + 21 files changed, 1622 insertions(+), 265 deletions(-) create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial-holding-groups.sql create mode 100644 Open-ILS/src/templates/opac/parts/record/issues-db.tt2 create mode 100644 Open-ILS/src/templates/opac/parts/record/issues-mfhd.tt2 diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index f4127c2098..fe0af0dc96 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -4185,6 +4185,7 @@ SELECT usr, + @@ -4477,7 +4478,32 @@ SELECT usr, - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml index 9708e9e36b..2eeffb7d06 100644 --- a/Open-ILS/src/extras/ils_events.xml +++ b/Open-ILS/src/extras/ils_events.xml @@ -1001,6 +1001,11 @@ Checkin attempted on item during minimum transit checkin interval. + + A serial pattern code has been configured + that fails to conform to MFHD standards for fields 853-855. + + diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm index 07bd151db3..2bc09cfe1e 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm @@ -1470,7 +1470,15 @@ sub get_org_descendants { } sub get_org_ancestors { - my($self, $org_id) = @_; + my($self, $org_id, $use_cache) = @_; + + my ($cache, $orgs); + + if ($use_cache) { + $cache = OpenSRF::Utils::Cache->new("global", 0); + $orgs = $cache->get_cache("org.ancestors.$org_id"); + return $orgs if $orgs; + } my $org_list = OpenILS::Utils::CStoreEditor->new->json_query({ select => { @@ -1485,9 +1493,10 @@ sub get_org_ancestors { where => {id => $org_id} }); - my @orgs; - push(@orgs, $_->{id}) for @$org_list; - return \@orgs; + $orgs = [ map { $_->{id} } @$org_list ]; + + $cache->put_cache("org.ancestors.$org_id", $orgs) if $use_cache; + return $orgs; } sub get_org_full_path { @@ -1975,5 +1984,118 @@ sub log_user_activity { return undef; } +# I hate to put this here exactly, but this code needs to be shared between +# the TPAC's mod_perl module and open-ils.serial. +# +# There is a reason every part of the query *except* those parts dealing +# with scope are moved here from the code's origin in TPAC. The serials +# use case does *not* want the same scoping logic. +# +# Also, note that for the serials uses case, we may filter in OPAC visible +# status and copy/call_number deletedness, but we don't filter on any +# particular values for serial.item.status or serial.item.date_received. +# Since we're only using this *after* winnowing down the set of issuances +# that copies should be related to, I'm not sure we need any such serial.item +# filters. + +sub basic_opac_copy_query { + ###################################################################### + # Pass a defined value for either $rec_id OR ($iss_id AND $dist_id), # + # not both. # + ###################################################################### + my ($self,$rec_id,$iss_id,$dist_id,$copy_limit,$copy_offset,$staff) = @_; + + return { + select => { + acp => ['id', 'barcode', 'circ_lib', 'create_date', + 'age_protect', 'holdable'], + acpl => [ + {column => 'name', alias => 'copy_location'}, + {column => 'holdable', alias => 'location_holdable'} + ], + ccs => [ + {column => 'name', alias => 'copy_status'}, + {column => 'holdable', alias => 'status_holdable'} + ], + acn => [ + {column => 'label', alias => 'call_number_label'}, + {column => 'id', alias => 'call_number'} + ], + circ => ['due_date'], + acnp => [ + {column => 'label', alias => 'call_number_prefix_label'}, + {column => 'id', alias => 'call_number_prefix'} + ], + acns => [ + {column => 'label', alias => 'call_number_suffix_label'}, + {column => 'id', alias => 'call_number_suffix'} + ], + bmp => [ + {column => 'label', alias => 'part_label'}, + ], + ($iss_id ? (sitem => ["issuance"]) : ()) + }, + + from => { + acp => { + ($iss_id ? ( + sitem => { + fkey => 'id', + field => 'unit', + filter => {issuance => $iss_id}, + join => { + sstr => { } + } + } + ) : ()), + acn => { + join => { + acnp => { fkey => 'prefix' }, + acns => { fkey => 'suffix' } + }, + filter => [ + {deleted => 'f'}, + ($rec_id ? {record => $rec_id} : ()) + ], + }, + circ => { # If the copy is circulating, retrieve the open circ + type => 'left', + filter => {checkin_time => undef} + }, + acpl => { + ($staff ? () : (filter => { opac_visible => 't' })) + }, + ccs => { + ($staff ? () : (filter => { opac_visible => 't' })) + }, + aou => {}, + acpm => { + type => 'left', + join => { + bmp => { type => 'left' } + } + } + } + }, + + where => { + '+acp' => { + deleted => 'f', + ($staff ? () : (opac_visible => 't')) + }, + ($dist_id ? ( '+sstr' => { distribution => $dist_id } ) : ()), + ($staff ? () : ( '+aou' => { opac_visible => 't' } )) + }, + + order_by => [ + {class => 'aou', field => 'name'}, + {class => 'acn', field => 'label'} + ], + + limit => $copy_limit, + offset => $copy_offset + }; +} + 1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm index 7acc52dc99..4c19954a69 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm @@ -50,6 +50,9 @@ use OpenILS::Utils::Fieldmapper; use OpenILS::Utils::MFHD; use DateTime::Format::ISO8601; use MARC::File::XML (BinaryEncoding => 'utf8'); + +use OpenILS::Application::Serial::OPAC; + my $U = 'OpenILS::Application::AppUtils'; my @MFHD_NAMES = ('basic','supplement','index'); my %MFHD_NAMES_BY_TAG = ( '853' => $MFHD_NAMES[0], @@ -503,6 +506,8 @@ sub pub_fleshed_serial_issuance_retrieve_batch { } sub received_siss_by_bib { + # XXX this is somewhat wrong in implementation and should not be used in + # new places - senator my $self = shift; my $client = shift; my $bib = shift; @@ -614,6 +619,8 @@ q/A hash of optional arguments. Valid keys and their meanings: sub scoped_bib_holdings_summary { + # XXX this is somewhat wrong in implementation and should not be used in + # new places - senator my $self = shift; my $client = shift; my $bibid = shift; @@ -643,7 +650,7 @@ __PACKAGE__->register_method( api_level => 1, argc => 1, signature => { - desc => 'Receives a Bib ID and other optional params and returns set of holdings statements', + desc => '** DEPRECATED and only used by JSPAC. Somewhat wrong in implementation. *** Receives a Bib ID and other optional params and returns set of holdings statements', params => [ { name => 'bibid', desc => 'id of the bre to which the issuances belong', diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm new file mode 100644 index 0000000000..65f19bb1d4 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm @@ -0,0 +1,633 @@ +package OpenILS::Application::Serial::OPAC; + +# This package contains methods for open-ils.serial that present data suitable +# for OPAC display. + +use base qw/OpenILS::Application/; +use strict; +use warnings; + +# All of the packages we might 'use' are already imported in +# OpenILS::Application::Serial. Only those that export symbols +# need to be mentioned explicitly here. + +use OpenSRF::Utils::Logger qw/:logger/; +use OpenILS::Utils::CStoreEditor q/:funcs/; + +my $U = "OpenILS::Application::AppUtils"; + +my %MFHD_SUMMARIZED_SUBFIELDS = ( + enum => [ split //, "abcdef" ], # $g and $h intentionally omitted for now + chron => [ split //, "ijklm" ] +); + +# This is a helper for scoped_holding_summary_tree_for_bib() a little further down + +sub _place_org_node { + my ($node, $tree, $org_tree) = @_; + + my @ancestry = reverse @{ $U->get_org_ancestors($node->{org_unit}, 1) }; + shift @ancestry; # discard current org_unit + + foreach (@ancestry) { # in leaf-to-root order + my $graft_point = _find_ou_in_holdings_tree($tree, $_); + + if ($graft_point) { + push @{$graft_point->{children}}, $node; + return; + } else { + $node = { + org_unit => $_, + holding_summaries => [], + children => [$node] + } + } + } + + # If we reach this point, we got all the way to the top of the org tree + # without finding corresponding nodes in $tree (holdings tree), so the + # latter must be empty, and we need to make $tree just contain what $node + # contains. + + %$tree = %$node; +} + +# This is a helper for scoped_holding_summary_tree_for_bib() a little further down + +sub _find_ou_in_holdings_tree { + my ($tree, $id) = @_; + + return $tree if $tree->{org_unit} eq $id; + if (ref $tree->{children}) { + foreach (@{$tree->{children}}) { + my $maybe = _find_ou_in_holdings_tree($_, $id); + return $maybe if $maybe; + } + } + + return; +} + +sub scoped_holding_summary_tree_for_bib { + my ( + $self, $client, $bib, $org_unit, $depth, $limit, $offset, $ascending + ) = @_; + + my $org_tree = $U->get_org_tree; # caches + + $org_unit ||= $org_tree->id; + $depth ||= 0; + $limit ||= 10; + $offset ||= 0; + + my $e = new_editor; + + # What we want to know from this query is essentially the set of + # holdings related to a given bib and the org units that have said + # holdings. + + # For this we would only need sasum, sdist and ssub, but + # because we also need to be able to page (and therefore must sort) the + # results we get, we need reasonable columns on which to do the sorting. + # So for that we join sitem (via sstr) so we can sort on the maximum + # date_expected (which is basically the issue pub date) for items that + # have been received. That maximum date_expected is actually the second + # sort key, however. The first is the holding lib's position in a + # depth-first representation of the org tree (if you think about it, + # paging through holdings held at diverse points in the tree only makes + # sense if you do it this way). + + my $rows = $e->json_query({ + select => { + sasum => [qw/summary_type id generated_coverage/], + sdist => ["holding_lib"], + sitem => [ + {column => "date_expected", transform => "max", aggregate => 1} + ] + }, + from => { + sasum => { + sdist => { + join => { + ssub => {}, + sstr => { + join => {sitem => {}} + }, + } + } + } + }, + where => { + "+sdist" => { + holding_lib => + $U->get_org_descendants(int($org_unit), int($depth)) + }, + "+ssub" => {record_entry => int($bib)}, + "+sitem" => {date_received => {"!=" => undef}} + }, + limit => int($limit) + 1, # see comment below on "limit trick" + offset => int($offset), + order_by => [ + { + class => "sdist", + field => "holding_lib", + transform => "actor.org_unit_simple_path", + params => [$org_tree->id] + }, + { + class => "sitem", + field => "date_expected", + transform => "max", # to match select clause + direction => ($ascending ? "ASC" : "DESC") + } + ], + }) or return $e->die_event; + + $e->disconnect; + + # Now we build a tree out of our result set. + my $result = {}; + + # Use our "limit trick" from above to cheaply determine whether there's + # another page of results, for the UI's benefit. Put $more into the + # result hash at the very end. + my $more = 0; + if (scalar(@$rows) > int($limit)) { + $more = 1; + pop @$rows; + } + + foreach my $row (@$rows) { + my $org_node_needs_placed = 0; + my $org_node = + _find_ou_in_holdings_tree($result, $row->{holding_lib}); + + if (not $org_node) { + $org_node_needs_placed = 1; + $org_node = { + org_unit => $row->{holding_lib}, + holding_summaries => [], + children => [] + }; + } + + # Make a very simple object for a single holding summary. + # generated_coverage is stored as JSON, and here we can unpack it. + my $summary = { + id => $row->{id}, + summary_type => $row->{summary_type}, + generated_coverage => + OpenSRF::Utils::JSON->JSON2perl($row->{generated_coverage}) + }; + + push @{$org_node->{holding_summaries}}, $summary; + + if ($org_node_needs_placed) { + _place_org_node($org_node, $result, $org_tree); + } + } + + $result->{more} = $more; + return $result; +} + +__PACKAGE__->register_method( + method => "scoped_holding_summary_tree_for_bib", + api_name => "open-ils.serial.holding_summary_tree.by_bib", + api_level => 1, + argc => 6, + signature => { + desc => 'Return a set of holding summaries organized into a tree + of nodes that look like: + {org_unit:, holding_summaries:[], children:[]} + + The root node has an extra key: "more". Its value is 1 if there + are more pages (in the limit/offset sense) of results that the caller + could potentially fetch. + + All arguments except the first (bibid) are optional. + ', + params => [ + { name => "bibid", + desc => "ID of the bre to which holdings belong", + type => "number" + }, + { name => "org_unit", type => "number" }, + { name => "depth (default 0)", type => "number" }, + { name => "limit (default 10)", type => "number" }, + { name => "offset (default 0)", type => "number" }, + { name => "ascending (default false)", type => "boolean" }, + ] + } +); + +# This is a helper for grouped_holdings_for_summary() later. +sub _label_holding_level { + my ($pattern_field, $subfield, $value, $mfhd_cache) = @_; + + # This is naïve, in that a-f are sometimes chron fields and not enum. + # OpenILS::Utils::MFHD understands that, but so far I don't think our + # interfaces do. + + my $cache_key = $subfield . $value; + + if (not exists $mfhd_cache->{$cache_key}) { + my $link_id = (split(/\./, $pattern_field->subfield('8')))[0]; + my $fake_holding = new MFHD::Holding( + 1, + new MARC::Field('863', '4', '1', '8', "$link_id.1"), + new MFHD::Caption($pattern_field->clone) + ); + + if ($subfield ge 'i') { # chron + $mfhd_cache->{$cache_key} = $fake_holding->format_single_chron( + {$subfield => $value}, $subfield, 1, 1 + ); + } else { # enum + $mfhd_cache->{$cache_key} = $fake_holding->format_single_enum( + {$subfield => $value}, $subfield, 1 + ); + } + } + + return $mfhd_cache->{$cache_key}; +} + +# This is a helper for grouped_holdings_for_summary() later. +sub _get_deepest_holding_level { + my ($display_grouping, $pattern_field) = @_; + + my @present = grep { $pattern_field->subfield($_) } @{ + $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping} + }; + + return pop @present; +} + +# This is a helper for grouped_holdings_for_summary() later. +sub _opac_visible_unit_data { + my ($issuance_id_list, $dist_id, $staff, $e) = @_; + + return {} unless @$issuance_id_list; + + my $rows = $e->json_query( + $U->basic_opac_copy_query( + undef, $issuance_id_list, $dist_id, + 1000, 0, # XXX no mechanism for users to page at this level yet + $staff + ) + ) or return $e->die_event; + + my $results = {}; + + # Take the list of rows returned from json_query() and sort results into + # several smaller lists stored in a hash keyed by issuance ID. + foreach my $row (@$rows) { + $results->{$row->{issuance}} = [] unless + exists $results->{$row->{issuance}}; + push @{ $results->{$row->{issuance}} }, $row; + } + + return $results; +} + +# This is a helper for grouped_holdings_for_summary() later. +sub _make_grouped_holding_node { + my ( + $row, $subfield, $deepest_level, $pattern_field, + $unit_data, $mfhd_cache + ) = @_; + + return { + $subfield eq $deepest_level ? ( + label => $row->{label}, + holding => $row->{id}, + ($unit_data ? (units => ($unit_data->{$row->{id}} || [])) : ()) + ) : ( + value => $row->{value}, + label => _label_holding_level( + $pattern_field, $subfield, $row->{value}, $mfhd_cache + ) + ) + }; +} + +# This is a helper for grouped_holdings_for_summary() later. +sub _make_single_level_grouped_holding_query { + my ( + $subfield, $deepest_level, $summary_hint, $summary_id, + $subfield_joins, $subfield_where_clauses, + $limit, $offsets + ) = @_; + + return { + select => { + sstr => ["distribution"], + "smhc_$subfield" => ["value"], ( + $subfield eq $deepest_level ? + (siss => [qw/id label date_published/]) : () + ) + }, + from => { + $summary_hint => { + sdist => { + join => { + sstr => { + join => { + sitem => { + join => { + siss => { + join => {%$subfield_joins} + } + } + } + } + } + } + } + } + }, + where => { + "+$summary_hint" => {id => $summary_id}, + "+sitem" => {date_received => {"!=" => undef}}, + %$subfield_where_clauses + }, + distinct => 1, # sic, this goes here in json_query + limit => int($limit) + 1, + offset => int(shift(@$offsets)), + order_by => { + "smhc_$subfield" => { + "value" => { + direction => ($subfield eq $deepest_level ? "asc" : "desc") + } + } + } + }; +} + +sub grouped_holdings_for_summary { + my ( + $self, $client, $summary_type, $summary_id, + $expand_path, $limit, $offsets, $auto_expand_first, $with_units + ) = @_; + + # Validate input or set defaults. + ($summary_type .= "") =~ s/[^\w]//g; + $summary_id = int($summary_id); + $expand_path ||= []; + $limit ||= 10; + $limit = 10 if $limit < 1; + $offsets ||= [0]; + + foreach ($expand_path, $offsets) { + if (ref $_ ne 'ARRAY') { + return new OpenILS::Event( + "BAD_PARAMS", note => + "'expand_path' and 'offsets' arguments must be arrays" + ); + } + } + + if (scalar(@$offsets) != scalar(@$expand_path) + 1) { + return new OpenILS::Event( + "BAD_PARAMS", note => + "'offsets' array must be one element longer than 'expand_path'" + ); + } + + # Get the class hint for whichever type of summary we're expanding. + my $fmclass = "Fieldmapper::serial::${summary_type}_summary"; + my $summary_hint = $Fieldmapper::fieldmap->{$fmclass}{hint} or + return new OpenILS::Event("BAD_PARAMS", note => "summary_type"); + + my $e = new_editor; + + # First, get display grouping for requested summary (either chron or enum) + # and the pattern code. Even though we have to JOIN through sitem to get + # pattern_code from scap, we don't actually care about specific items yet. + my $row = $e->json_query({ + select => {sdist => ["display_grouping"], scap => ["pattern_code"]}, + from => { + $summary_hint => { + sdist => { + join => { + sstr => { + join => { + sitem => { + join => { + siss => { + join => {scap => {}} + } + } + } + } + } + } + } + } + }, + where => { + "+$summary_hint" => {id => $summary_id}, + "+sitem" => {date_received => {"!=" => undef}} + }, + limit => 1 + }) or return $e->die_event; + + # Summaries without attached holdings constitute bad data, not benign + # empty result sets. + return new OpenILS::Event( + "BAD_PARAMS", + note => "Summary #$summary_id not found, or no holdings attached" + ) unless @$row; + + # Unless data has been disarranged, all holdings grouped together under + # the same summary should have the same pattern code, so we can take any + # result from the set we just got. + my $pattern_field; + eval { + $pattern_field = new MARC::Field( + "853", # irrelevant for our purposes + @{ OpenSRF::Utils::JSON->JSON2perl($row->[0]->{pattern_code}) } + ); + }; + if ($@) { + return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $@); + } + + # And now we know which subfields we will care about from + # serial.materialized_holding_code. + my $display_grouping = $row->[0]->{display_grouping}; + + # This will tell us when to stop grouping and start showing actual + # holdings. + my $deepest_level = + _get_deepest_holding_level($display_grouping, $pattern_field); + if (not defined $deepest_level) { + # corrupt pattern code + my $msg = "couldn't determine deepest holding level for " . + "$summary_type summary #$summary_id"; + $logger->warn($msg); + return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $msg); + } + + my @subfields = @{ $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping} }; + + # We look for holdings grouped at the top level once no matter what, + # then we'll look deeper with additional queries for every element of + # $expand_path later. + # Below we define parts of the SELECT and JOIN clauses that we'll + # potentially reuse if $expand_path has elements. + + my $subfield = shift @subfields; + my %subfield_joins = ("smhc_$subfield" => {class => "smhc"}); + my %subfield_where_clauses = ("+smhc_$subfield" => {subfield => $subfield}); + + # Now get the top level of holdings. + my $top = $e->json_query( + _make_single_level_grouped_holding_query( + $subfield, $deepest_level, $summary_hint, $summary_id, + \%subfield_joins, \%subfield_where_clauses, + $limit, $offsets + ) + ) or return $e->die_event; + + # Deal with the extra row, if present, that tells are there are more pages + # of results. + my $top_more = 0; + if (scalar(@$top) > int($limit)) { + $top_more = 1; + pop @$top; + } + + # Distribution is the same for all rows anyway, but we may need it for a + # copy query later. + my $dist_id = @$top ? $top->[0]->{distribution} : undef; + + # This will help us avoid certain repetitive calculations. Examine + # _label_holding_level() to see what I mean. + my $mfhd_cache = {}; + + # Prepare related unit data if appropriate. + my $unit_data; + + if ($with_units and $subfield eq $deepest_level) { + $unit_data = _opac_visible_unit_data( + [map { $_->{id} } @$top], $dist_id, $with_units > 1, $e + ); + return $unit_data if defined $U->event_code($unit_data); + } + + # Make the tree we have so far. + my $tree = [ + map( + _make_grouped_holding_node( + $_, $subfield, $deepest_level, $pattern_field, + $unit_data, $mfhd_cache + ), + @$top + ), ($top_more ? undef : ()) + ]; + + # We'll need a parent reference at each level as we descend. + my $parent = $tree; + + # Will we be trying magic auto-expansion of the first top-level grouping? + if ($auto_expand_first and @$tree and not @$expand_path) { + $expand_path = [$tree->[0]->{value}]; + $offsets = [0]; + } + + # Ok, that got us the top level, with nothing expanded. Now we loop through + # the elements of @$expand_path, issuing similar queries to get us deeper + # groupings and even actual specific holdings. + foreach my $value (@$expand_path) { + my $prev_subfield = $subfield; + $subfield = shift @subfields; + + # This wad of JOINs is additive over each iteration. + $subfield_joins{"smhc_$subfield"} = {class => "smhc"}; + + # The WHERE clauses also change and grow each time. + $subfield_where_clauses{"+smhc_$prev_subfield"}->{value} = $value; + $subfield_where_clauses{"+smhc_$subfield"}->{subfield} = $subfield; + + my $level = $e->json_query( + _make_single_level_grouped_holding_query( + $subfield, $deepest_level, $summary_hint, $summary_id, + \%subfield_joins, \%subfield_where_clauses, + $limit, $offsets + ) + ) or return $e->die_event; + + return $tree unless @$level; + + # Deal with the extra row, if present, that tells are there are more + # pages of results. + my $level_more = 0; + if (scalar(@$level) > int($limit)) { + $level_more = 1; + pop @$level; + } + + # Find attachment point for our results. + my ($point) = grep { ref $_ and $_->{value} eq $value } @$parent; + + # Prepare related unit data if appropriate. + if ($with_units and $subfield eq $deepest_level) { + $unit_data = _opac_visible_unit_data( + [map { $_->{id} } @$level], $dist_id, $with_units > 1, $e + ); + return $unit_data if defined $U->event_code($unit_data); + } + + # Set parent for the next iteration. + $parent = $point->{children} = [ + map( + _make_grouped_holding_node( + $_, $subfield, $deepest_level, $pattern_field, + $unit_data, $mfhd_cache + ), + @$level + ), ($level_more ? undef : ()) + ]; + + last if $subfield eq $deepest_level; + } + + return $tree; +} + +__PACKAGE__->register_method( + method => "grouped_holdings_for_summary", + api_name => "open-ils.serial.holdings.grouped_by_summary", + api_level => 1, + argc => 7, + signature => { + desc => q/Return a tree of holdings associated with a given summary + grouped by all but the last of either chron or enum units./, + params => [ + { name => "summary_type", type => "string" }, + { name => "summary_id", type => "number" }, + { name => "expand_path", type => "array", + desc => "In root-to-leaf order, the values of the nodes along the axis you want to expand" }, + { name => "limit (default 10)", type => "number" }, + { name => "offsets", type => "array", desc => + "This must be exactly one element longer than expand_path" }, + { name => "auto_expand_first", type => "boolean", desc => + "Only if expand_path is empty, automatically expand first top-level grouping" }, + { name => "with_units", type => "number", desc => q/ + If true at all, for each holding, if there are associated units, + add some information about them to the result tree. These units + will be filtered by OPAC visibility unless you provide a value + greater than 1. + + IOW: + 0 = no units, + 1 = opac visible units, + 2 = all units (i.e. staff view) + / } + ] + } +); + +1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD/Holding.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD/Holding.pm index 9b673c2722..920bfd2a20 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD/Holding.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD/Holding.pm @@ -233,6 +233,72 @@ sub subfields_list { } return @subfields; } +my %__mfhd_month_labels = ( + '01' => ['Jan.', 'January'], + '02' => ['Feb.', 'February'], + '03' => ['Mar.', 'March'], + '04' => ['Apr.', 'April'], + '05' => ['May ', 'May'], + '06' => ['Jun.', 'June'], + '07' => ['Jul.', 'July'], + '08' => ['Aug.', 'August'], + '09' => ['Sep.', 'September'], + '10' => ['Oct.', 'October'], + '11' => ['Nov.', 'November'], + '12' => ['Dec.', 'December'], + '21' => 'Spring', + '22' => 'Summer', + '23' => 'Autumn', + '24' => 'Winter' +); + +sub _get_mfhd_month_label { + my ($month, $long) = @_; + $long ||= 0; + + my $o = $__mfhd_month_labels{$month}; + return (ref $o) ? $o->[$long] : $o; +} + +# Called by method 'format_chron' +# +sub format_single_chron { + my $self = shift; + my $holdings = shift; + my $key = shift; + my $skip_sep = shift; + my $long = shift; + my $capstr; + my $chron; + my $sep = ':'; + + return if !defined $self->caption->capstr($key); + + $capstr = $self->caption->capstr($key); + if (substr($capstr, 0, 1) eq '(') { + # a caption enclosed in parentheses is not displayed + $capstr = ''; + } + + # If this is the second level of chronology, then it's + # likely to be a month or season, so we should use the + # string name rather than the number given. + if ($key eq 'b' or $key eq 'j') { + # account for possible combined issue chronology + my @chron_parts = split('/', $holdings->{$key}); + for (my $i = 0; $i < @chron_parts; $i++) { + my $month_label = _get_mfhd_month_label($chron_parts[$i], $long); + $chron_parts[$i] = $month_label if defined $month_label; + } + $chron = join('/', @chron_parts); + } else { + $chron = $holdings->{$key}; + } + + $skip_sep ||= ($key eq 'a' || $key eq 'i'); + + return ($skip_sep ? '' : $sep) . $capstr . $chron; +} # # Called by method 'format_part' for formatting the chronology portion of @@ -241,63 +307,49 @@ sub subfields_list { sub format_chron { my $self = shift; my $holdings = shift; - my $caption = $self->caption; my @keys = @_; my $str = ''; - my %month = ( - '01' => 'Jan.', - '02' => 'Feb.', - '03' => 'Mar.', - '04' => 'Apr.', - '05' => 'May ', - '06' => 'Jun.', - '07' => 'Jul.', - '08' => 'Aug.', - '09' => 'Sep.', - '10' => 'Oct.', - '11' => 'Nov.', - '12' => 'Dec.', - '21' => 'Spring', - '22' => 'Summer', - '23' => 'Autumn', - '24' => 'Winter' - ); - - foreach my $i (0..@keys) { - my $key = $keys[$i]; - my $capstr; - my $chron; - my $sep; - - last if !defined $caption->capstr($key); - - $capstr = $caption->capstr($key); - if (substr($capstr, 0, 1) eq '(') { - # a caption enclosed in parentheses is not displayed - $capstr = ''; - } - - # If this is the second level of chronology, then it's - # likely to be a month or season, so we should use the - # string name rather than the number given. - if (($i == 1)) { - # account for possible combined issue chronology - my @chron_parts = split('/', $holdings->{$key}); - for (my $i = 0; $i < @chron_parts; $i++) { - $chron_parts[$i] = $month{$chron_parts[$i]} if exists $month{$chron_parts[$i]}; - } - $chron = join('/', @chron_parts); - } else { - $chron = $holdings->{$key}; - } - $str .= (($i == 0 || $str =~ /[. ]$/) ? '' : ':') . $capstr . $chron; + foreach my $key (@keys) { + my $skip_sep = ($str =~ /[. ]$/); + my $new_part = $self->format_single_chron($holdings, $key, $skip_sep); + last unless defined $new_part; + $str .= $new_part; } return $str; } # +# Called by method 'format_part' for each enum subfield +# +sub format_single_enum { + my $self = shift; + my $holding_values = shift; + my $key = shift; + my $skip_sep = shift; + my $capstr; + my $chron; + my $sep = ':'; + + return if !defined $self->caption->capstr($key); + + $capstr = $self->caption->capstr($key); + if (substr($capstr, 0, 1) eq '(') { + # a caption enclosed in parentheses is not displayed + $capstr = ''; + } elsif ($skip_sep) { + # We'll let a $skip_sep parameter of true mean what it means down by + # the return statement AND to pad the caption itself here. + $capstr .= ' '; + } + + + $skip_sep ||= ($key eq 'a'); + return ($skip_sep ? '' : $sep) . $capstr . $holding_values->{$key}; +} + +# # Called by method 'format' for each member of a possibly compressed holding # sub format_part { @@ -321,19 +373,9 @@ sub format_part { # Enumerations foreach my $key ('a'..'f') { - my $capstr; - my $chron; - my $sep; - - last if !defined $caption->capstr($key); - - $capstr = $caption->capstr($key); - if (substr($capstr, 0, 1) eq '(') { - # a caption enclosed in parentheses is not displayed - $capstr = ''; - } - $str .= - ($key eq 'a' ? '' : ':') . $capstr . $holding_values->{$key}; + my $new_part = $self->format_single_enum($holding_values, $key); + last unless defined $new_part; + $str .= $new_part; } # Chronology 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 bc74bd19c8..abb8265cc2 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm @@ -70,13 +70,9 @@ sub load_record { $ctx->{get_org_setting}-> ($org, "opac.fully_compressed_serial_holdings") ) { - $ctx->{holding_summaries} = - $self->get_holding_summaries($rec_id, $org, $copy_depth); - - $ctx->{have_holdings_to_show} = - scalar(@{$ctx->{holding_summaries}->{basic}}) || - scalar(@{$ctx->{holding_summaries}->{index}}) || - scalar(@{$ctx->{holding_summaries}->{supplement}}); + # We're loading this data here? Are we therefore assuming that we + # *are* going to display something in the "issues" expandy? + $self->load_serial_holding_summaries($rec_id, $org, $copy_depth); } else { $ctx->{mfhd_summaries} = $self->get_mfhd_summaries($rec_id, $org, $copy_depth); @@ -92,9 +88,8 @@ sub load_record { $ctx->{marchtml} = $self->mk_marc_html($rec_id); }, issues => sub { - $ctx->{expanded_holdings} = - $self->get_expanded_holdings($rec_id, $org, $copy_depth) - if $ctx->{have_holdings_to_show}; + return; + # XXX this needed? }, cnbrowse => sub { $self->prepare_browse_call_numbers(); @@ -152,78 +147,9 @@ sub mk_copy_query { my $copy_offset = shift; my $pref_ou = shift; - my $query = { - select => { - acp => ['id', 'barcode', 'circ_lib', 'create_date', 'age_protect', 'holdable'], - acpl => [ - {column => 'name', alias => 'copy_location'}, - {column => 'holdable', alias => 'location_holdable'} - ], - ccs => [ - {column => 'name', alias => 'copy_status'}, - {column => 'holdable', alias => 'status_holdable'} - ], - acn => [ - {column => 'label', alias => 'call_number_label'}, - {column => 'id', alias => 'call_number'} - ], - circ => ['due_date'], - acnp => [ - {column => 'label', alias => 'call_number_prefix_label'}, - {column => 'id', alias => 'call_number_prefix'} - ], - acns => [ - {column => 'label', alias => 'call_number_suffix_label'}, - {column => 'id', alias => 'call_number_suffix'} - ], - bmp => [ - {column => 'label', alias => 'part_label'}, - ] - }, - - from => { - acp => { - acn => { - join => { - acnp => { fkey => 'prefix' }, - acns => { fkey => 'suffix' } - }, - filter => [{deleted => 'f'}, {record => $rec_id}], - }, - circ => { # If the copy is circulating, retrieve the open circ - type => 'left', - filter => {checkin_time => undef} - }, - acpl => {}, - ccs => {}, - aou => {}, - acpm => { - type => 'left', - join => { - bmp => { type => 'left' } - } - } - } - }, - - where => { - '+acp' => {deleted => 'f' } - }, - - order_by => [ - { class => "aou", field => 'id', - transform => 'evergreen.rank_ou', params => [$org, $pref_ou] - }, - {class => 'aou', field => 'name'}, - {class => 'acn', field => 'label'}, - { class => "acp", field => 'status', - transform => 'evergreen.rank_cp_status' - } - ], - - limit => $copy_limit, - offset => $copy_offset - }; + my $query = $U->basic_opac_copy_query( + $rec_id, undef, undef, $copy_limit, $copy_offset, $self->ctx->{is_staff} + ); if($org != $self->ctx->{aou_tree}->()->id) { # no need to add the org join filter if we're not actually filtering @@ -247,13 +173,17 @@ sub mk_copy_query { }; }; - # Filter hidden items if this is the public catalog - unless($self->ctx->{is_staff}) { - $query->{where}->{'+acp'}->{opac_visible} = 't'; - $query->{from}->{'acp'}->{'acpl'}->{filter} = {opac_visible => 't'}; - $query->{from}->{'acp'}->{'ccs'}->{filter} = {opac_visible => 't'}; - $query->{where}->{'+aou'}->{opac_visible} = 't'; - } + # Unsure if we want these in the shared function, leaving here for now + unshift(@{$query->{order_by}}, + { class => "aou", field => 'id', + transform => 'evergreen.rank_ou', params => [$org, $pref_ou] + } + ); + push(@{$query->{order_by}}, + { class => "acp", field => 'status', + transform => 'evergreen.rank_cp_status' + } + ); return $query; } @@ -267,47 +197,101 @@ sub mk_marc_html { 'open-ils.search.biblio.record.html', $rec_id, 1); } -sub get_holding_summaries { +sub load_serial_holding_summaries { my ($self, $rec_id, $org, $depth) = @_; + my $limit = $self->cgi->param("slimit") || 10; + my $offset = $self->cgi->param("soffset") || 0; + my $serial = create OpenSRF::AppSession("open-ils.serial"); - my $result = $serial->request( - "open-ils.serial.bib.summary_statements", - $rec_id, {"org_id" => $org, "depth" => $depth} + + # First, get the tree of /summaries/ of holdings. + my $tree = $serial->request( + "open-ils.serial.holding_summary_tree.by_bib", + $rec_id, $org, $depth, $limit, $offset )->gather(1); + return if $self->apache_log_if_event( + $tree, "getting holding summary tree for record $rec_id" + ); + + # Next, if requested, get a list of individual holdings under a + # particular summary. + my $holdings; + my $summary_id = int($self->cgi->param("sid") || 0); + my $summary_type = $self->cgi->param("stype"); + + if ($summary_id and $summary_type) { + my $expand_path = [ $self->cgi->param("sepath") ], + my $expand_limit = $self->cgi->param("selimit"); + my $expand_offsets = [ $self->cgi->param("seoffset") ]; + my $auto_expand_first = 0; + + if (not @$expand_offsets) { + $expand_offsets = undef; + $auto_expand_first = 1; + } + + $holdings = $serial->request( + "open-ils.serial.holdings.grouped_by_summary", + $summary_type, $summary_id, + $expand_path, $expand_limit, $expand_offsets, + $auto_expand_first, + 1 + ($self->ctx->{is_staff} ? 1 : 0) + )->gather(1); + + if ($holdings and ref $holdings eq "ARRAY") { + $self->place_holdings_with_summary( + $tree, $holdings, $summary_id, $summary_type + ) or $self->apache->log->warn( + "could not place holdings within summary tree" + ); + } else { + $self->apache_log_if_event( + $holdings, "getting holdings grouped by summary $summary_id" + ); + } + } + $serial->kill_me; - return $result; + + # The presence of any keys in the tree hash other than 'more' means that we + # must have /something/ we could show. + $self->ctx->{have_holdings_to_show} = grep { $_ ne 'more' } (keys %$tree); + + $self->ctx->{holding_summary_tree} = $tree; } -sub get_mfhd_summaries { - my ($self, $rec_id, $org, $depth) = @_; +# This helper to load_serial_holding_summaries() recursively searches in +# $tree for a holding summary matching $sid and $stype, and places $holdings +# within the node for that summary. IOW, this is about showing expanded +# holdings under their "parent" summary. +sub place_holdings_with_summary { + my ($self, $tree, $holdings, $sid, $stype) = @_; + + foreach my $sum (@{$tree->{holding_summaries}}) { + if ($sum->{id} == $sid and $sum->{summary_type} eq $stype) { + $sum->{holdings} = $holdings; + return 1; + } + } - my $serial = create OpenSRF::AppSession("open-ils.search"); - my $result = $serial->request( - "open-ils.search.serial.record.bib.retrieve", - $rec_id, $org, $depth - )->gather(1); + foreach my $child (@{$tree->{children}}) { + return 1 if $self->place_holdings_with_summary( + $child, $holdings, $sid, $stype + ); + } - $serial->kill_me; - return $result; + return; } -sub get_expanded_holdings { +sub get_mfhd_summaries { my ($self, $rec_id, $org, $depth) = @_; - my $holding_limit = int($self->cgi->param("holding_limit") || 10); - my $holding_offset = int($self->cgi->param("holding_offset") || 0); - my $type = $self->cgi->param("expand_holding_type"); - - my $serial = create OpenSRF::AppSession("open-ils.serial"); + my $serial = create OpenSRF::AppSession("open-ils.search"); my $result = $serial->request( - "open-ils.serial.received_siss.retrieve.by_bib.atomic", - $rec_id, { - "ou" => $org, "depth" => $depth, - "limit" => $holding_limit, "offset" => $holding_offset, - "type" => $type - } + "open-ils.search.serial.record.bib.retrieve", + $rec_id, $org, $depth )->gather(1); $serial->kill_me; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm index e9b61f477a..f46587f551 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm @@ -421,4 +421,27 @@ sub set_file_download_headers { return Apache2::Const::OK; } +sub apache_log_if_event { + my ($self, $event, $prefix_text, $success_ok, $level) = @_; + + $prefix_text ||= "Evergreen returned event"; + $success_ok ||= 0; + $level ||= "warn"; + + chomp $prefix_text; + $prefix_text .= ": "; + + my $code = $U->event_code($event); + if (defined $code and ($code or not $success_ok)) { + $self->apache->log->$level( + $prefix_text . + ($event->{textcode} || "") . " ($code)" . + ($event->{note} ? (": " . $event->{note}) : "") + ); + return 1; + } + + return; +} + 1; diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql index eaf0e9fdb7..4c4379a959 100644 --- a/Open-ILS/src/sql/Pg/002.schema.config.sql +++ b/Open-ILS/src/sql/Pg/002.schema.config.sql @@ -46,6 +46,7 @@ INSERT INTO config.internal_flag (name) VALUES ('ingest.disable_metabib_full_rec INSERT INTO config.internal_flag (name) VALUES ('ingest.disable_metabib_rec_descriptor'); INSERT INTO config.internal_flag (name) VALUES ('ingest.disable_metabib_field_entry'); INSERT INTO config.internal_flag (name) VALUES ('ingest.assume_inserts_only'); +INSERT INTO config.internal_flag (name) VALUES ('serial.rematerialize_on_same_holding_code'); CREATE TABLE config.global_flag ( label TEXT NOT NULL diff --git a/Open-ILS/src/sql/Pg/020.schema.functions.sql b/Open-ILS/src/sql/Pg/020.schema.functions.sql index ca4306eb2e..de70132ead 100644 --- a/Open-ILS/src/sql/Pg/020.schema.functions.sql +++ b/Open-ILS/src/sql/Pg/020.schema.functions.sql @@ -221,6 +221,35 @@ CREATE OR REPLACE FUNCTION actor.org_unit_common_ancestors ( INT, INT ) RETURNS FROM actor.org_unit_ancestors($2); $$ LANGUAGE SQL STABLE ROWS 1; +-- Given the IDs of two rows in actor.org_unit, *the second being an ancestor +-- of the first*, return in array form the path from the ancestor to the +-- descendant, with each point in the path being an org_unit ID. This is +-- useful for sorting org_units by their position in a depth-first (display +-- order) representation of the tree. +-- +-- This breaks with the precedent set by actor.org_unit_full_path() and others, +-- and gets the parameters "backwards," but otherwise this function would +-- not be very usable within json_query. +CREATE OR REPLACE FUNCTION actor.org_unit_simple_path(INT, INT) +RETURNS INT[] AS $$ + WITH RECURSIVE descendant_depth(id, path) AS ( + SELECT aou.id, + ARRAY[aou.id] + FROM actor.org_unit aou + JOIN actor.org_unit_type aout ON (aout.id = aou.ou_type) + WHERE aou.id = $2 + UNION ALL + SELECT aou.id, + dd.path || ARRAY[aou.id] + FROM actor.org_unit aou + JOIN actor.org_unit_type aout ON (aout.id = aou.ou_type) + JOIN descendant_depth dd ON (dd.id = aou.parent_ou) + ) SELECT dd.path + FROM actor.org_unit aou + JOIN descendant_depth dd USING (id) + WHERE aou.id = $1 ORDER BY dd.path; +$$ LANGUAGE SQL STABLE ROWS 1; + CREATE OR REPLACE FUNCTION actor.org_unit_proximity ( INT, INT ) RETURNS INT AS $$ SELECT COUNT(id)::INT FROM ( SELECT id FROM actor.org_unit_combined_ancestors($1, $2) diff --git a/Open-ILS/src/sql/Pg/210.schema.serials.sql b/Open-ILS/src/sql/Pg/210.schema.serials.sql index 29617cd968..8a82141e66 100644 --- a/Open-ILS/src/sql/Pg/210.schema.serials.sql +++ b/Open-ILS/src/sql/Pg/210.schema.serials.sql @@ -105,6 +105,8 @@ CREATE TABLE serial.distribution ( REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED, label TEXT NOT NULL, + display_grouping TEXT NOT NULL DEFAULT 'chron' + CHECK (display_grouping IN ('enum', 'chron')), receive_call_number BIGINT REFERENCES asset.call_number (id) DEFERRABLE INITIALLY DEFERRED, receive_unit_template INT REFERENCES asset.copy_template (id) @@ -326,5 +328,92 @@ CREATE TABLE serial.index_summary ( ); CREATE INDEX serial_index_summary_dist_idx ON serial.index_summary (distribution); +CREATE VIEW serial.any_summary AS + SELECT + 'basic' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.basic_summary + UNION + SELECT + 'index' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.index_summary + UNION + SELECT + 'supplement' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.supplement_summary ; + + +CREATE TABLE serial.materialized_holding_code ( + id BIGSERIAL PRIMARY KEY, + issuance INTEGER NOT NULL REFERENCES serial.issuance (id) ON DELETE CASCADE, + holding_type TEXT NOT NULL, + ind1 TEXT, + ind2 TEXT, + subfield CHAR, + value TEXT +); + +CREATE OR REPLACE FUNCTION serial.materialize_holding_code() RETURNS TRIGGER +AS $func$ +use strict; + +use MARC::Field; +use JSON::XS; + +# Do nothing if holding_code has not changed... + +if ($_TD->{new}{holding_code} eq $_TD->{old}{holding_code}) { + # ... unless the following internal flag is set. + + my $flag_rv = spi_exec_query(q{ + SELECT * FROM config.internal_flag + WHERE name = 'serial.rematerialize_on_same_holding_code' AND enabled + }, 1); + return unless $flag_rv->{processed}; +} + + +my $holding_code = (new JSON::XS)->decode($_TD->{new}{holding_code}); + +my $field = new MARC::Field('999', @$holding_code); # tag doesnt matter + +my $dstmt = spi_prepare( + 'DELETE FROM serial.materialized_holding_code WHERE issuance = $1', + 'INT' +); +spi_exec_prepared($dstmt, $_TD->{new}{id}); + +my $istmt = spi_prepare( + q{ + INSERT INTO serial.materialized_holding_code ( + issuance, holding_type, ind1, ind2, subfield, value + ) VALUES ($1, $2, $3, $4, $5, $6) + }, qw{INT TEXT TEXT TEXT CHAR TEXT} +); + +foreach ($field->subfields) { + spi_exec_prepared( + $istmt, + $_TD->{new}{id}, + $_TD->{new}{holding_type}, + $field->indicator(1), + $field->indicator(2), + $_->[0], + $_->[1] + ); +} + +return; + +$func$ LANGUAGE 'plperlu'; + +CREATE INDEX assist_holdings_display + ON serial.materialized_holding_code (issuance, subfield); + +CREATE TRIGGER materialize_holding_code + AFTER INSERT OR UPDATE ON serial.issuance + FOR EACH ROW EXECUTE PROCEDURE serial.materialize_holding_code() ; 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 ccacf9f77c..2771725644 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -4664,6 +4664,21 @@ INSERT into config.org_unit_setting_type 'description' ), 'bool', null) +,( 'serial.default_display_grouping', 'serial' + oils_i18n_gettext( + 'serial.default_display_grouping', + 'Default display grouping for serials distributions presented in the OPAC.', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'serial.default_display_grouping', + 'Default display grouping for serials distributions presented in the OPAC. This can be "enum" or "chron".', + 'coust', + 'description' + ), + 'string', null) + ; UPDATE config.org_unit_setting_type @@ -10117,7 +10132,6 @@ INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,dat ), 'string' ); - SELECT setval( 'config.sms_carrier_id_seq', 1000 ); INSERT INTO config.sms_carrier VALUES diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial-holding-groups.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial-holding-groups.sql new file mode 100644 index 0000000000..f27fc79078 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial-holding-groups.sql @@ -0,0 +1,155 @@ +BEGIN; + +SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); + +INSERT INTO config.internal_flag (name, value, enabled) VALUES ( + 'serial.rematerialize_on_same_holding_code', NULL, FALSE +); + +INSERT INTO config.org_unit_setting_type ( + name, label, grp, description, datatype +) VALUES ( + 'serial.default_display_grouping', + 'Default display grouping for serials distributions presented in the OPAC.', + 'serial', + 'Default display grouping for serials distributions presented in the OPAC. This can be "enum" or "chron".', + 'string' +); + +ALTER TABLE serial.distribution + ADD COLUMN display_grouping TEXT NOT NULL DEFAULT 'chron' + CHECK (display_grouping IN ('enum', 'chron')); + +-- why didn't we just make one summary table in the first place? +CREATE VIEW serial.any_summary AS + SELECT + 'basic' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.basic_summary + UNION + SELECT + 'index' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.index_summary + UNION + SELECT + 'supplement' AS summary_type, id, distribution, + generated_coverage, textual_holdings, show_generated + FROM serial.supplement_summary ; + + +-- Given the IDs of two rows in actor.org_unit, *the second being an ancestor +-- of the first*, return in array form the path from the ancestor to the +-- descendant, with each point in the path being an org_unit ID. This is +-- useful for sorting org_units by their position in a depth-first (display +-- order) representation of the tree. +-- +-- This breaks with the precedent set by actor.org_unit_full_path() and others, +-- and gets the parameters "backwards," but otherwise this function would +-- not be very usable within json_query. +CREATE OR REPLACE FUNCTION actor.org_unit_simple_path(INT, INT) +RETURNS INT[] AS $$ + WITH RECURSIVE descendant_depth(id, path) AS ( + SELECT aou.id, + ARRAY[aou.id] + FROM actor.org_unit aou + JOIN actor.org_unit_type aout ON (aout.id = aou.ou_type) + WHERE aou.id = $2 + UNION ALL + SELECT aou.id, + dd.path || ARRAY[aou.id] + FROM actor.org_unit aou + JOIN actor.org_unit_type aout ON (aout.id = aou.ou_type) + JOIN descendant_depth dd ON (dd.id = aou.parent_ou) + ) SELECT dd.path + FROM actor.org_unit aou + JOIN descendant_depth dd USING (id) + WHERE aou.id = $1 ORDER BY dd.path; +$$ LANGUAGE SQL STABLE ROWS 1; + +CREATE TABLE serial.materialized_holding_code ( + id BIGSERIAL PRIMARY KEY, + issuance INTEGER NOT NULL REFERENCES serial.issuance (id) ON DELETE CASCADE, + holding_type TEXT NOT NULL, + ind1 TEXT, + ind2 TEXT, + subfield CHAR, + value TEXT +); + +CREATE OR REPLACE FUNCTION serial.materialize_holding_code() RETURNS TRIGGER +AS $func$ +use strict; + +use MARC::Field; +use JSON::XS; + +# Do nothing if holding_code has not changed... + +if ($_TD->{new}{holding_code} eq $_TD->{old}{holding_code}) { + # ... unless the following internal flag is set. + + my $flag_rv = spi_exec_query(q{ + SELECT * FROM config.internal_flag + WHERE name = 'serial.rematerialize_on_same_holding_code' AND enabled + }, 1); + return unless $flag_rv->{processed}; +} + + +my $holding_code = (new JSON::XS)->decode($_TD->{new}{holding_code}); + +my $field = new MARC::Field('999', @$holding_code); # tag doesnt matter + +my $dstmt = spi_prepare( + 'DELETE FROM serial.materialized_holding_code WHERE issuance = $1', + 'INT' +); +spi_exec_prepared($dstmt, $_TD->{new}{id}); + +my $istmt = spi_prepare( + q{ + INSERT INTO serial.materialized_holding_code ( + issuance, holding_type, ind1, ind2, subfield, value + ) VALUES ($1, $2, $3, $4, $5, $6) + }, qw{INT TEXT TEXT TEXT CHAR TEXT} +); + +foreach ($field->subfields) { + spi_exec_prepared( + $istmt, + $_TD->{new}{id}, + $_TD->{new}{holding_type}, + $field->indicator(1), + $field->indicator(2), + $_->[0], + $_->[1] + ); +} + +return; + +$func$ LANGUAGE 'plperlu'; + +CREATE INDEX assist_holdings_display + ON serial.materialized_holding_code (issuance, subfield); + +CREATE TRIGGER materialize_holding_code + AFTER INSERT OR UPDATE ON serial.issuance + FOR EACH ROW EXECUTE PROCEDURE serial.materialize_holding_code() ; + +-- starting here, we materialize all existing holding codes. + +UPDATE config.internal_flag + SET enabled = TRUE + WHERE name = 'serial.rematerialize_on_same_holding_code'; + +UPDATE serial.issuance SET holding_code = holding_code; + +UPDATE config.internal_flag + SET enabled = FALSE + WHERE name = 'serial.rematerialize_on_same_holding_code'; + +-- finish holding code materialization process + +COMMIT; diff --git a/Open-ILS/src/templates/opac/parts/header.tt2 b/Open-ILS/src/templates/opac/parts/header.tt2 index d67a1d3bd4..ba754129cc 100644 --- a/Open-ILS/src/templates/opac/parts/header.tt2 +++ b/Open-ILS/src/templates/opac/parts/header.tt2 @@ -26,7 +26,10 @@ # # Current page, clear 'some_param' from the existing params: # mkurl('', {foo => 'bar', boo => 'baz'}, ['some_param']); - MACRO mkurl(page, params, clear_params) BLOCK; + # + # Current page to a named anchor 'copies' + # mkurl('', {}, [], 'copies'); + MACRO mkurl(page, params, clear_params, named_anchor) BLOCK; # clone the query string to avoid clobberation cgi = CGI.new(CGI.query_string); @@ -46,7 +49,12 @@ FOR k IN params.keys; encoded = []; max = params.$k.max; - list = (params.$k.0 OR max == -1) ? params.$k : [params.$k]; + + # The following commented-out line can be fooled. Its replacement + # below is what you really mean. + # list = (params.$k.0 OR max == -1) ? params.$k : [params.$k]; + list = params.$k.list; + IF list.size == 0; NEXT; END; # CGI croaks on already-decoded strings. force-encode to be safe. FOR p IN list; encoded.push(ctx.encode_utf8(p)); END; @@ -64,15 +72,17 @@ END; END; + final = named_anchor ? '#' _ named_anchor : ''; + IF page; IF cgi.query_string; - page _ '?' _ cgi.query_string; + page _ '?' _ cgi.query_string _ final; ELSE; - page; + page _ final; END; ELSE; # staying on the current page - cgi.url("-path" => 1, "-query" => 1); + cgi.url("-path" => 1, "-query" => 1) _ final; END; END; 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 240f9703cd..b806f5cead 100644 --- a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 +++ b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 @@ -1,5 +1,19 @@ [%- -FOREACH copy_info IN ctx.copies; + +# If being used in serial mode, flatten list of units so that they can be +# used like one long list of copies without changing so much code below. +IF serial_holdings; + copies = []; + FOREACH h IN serial_holdings; + units = h.units.slice(0); # copy + FOREACH unit IN units; + unit.holding_label = h.label; + END; + copies = copies.merge(units); + END; +END; + +FOREACH copy_info IN copies; IF copy_info.call_number_label != '##URI##'; has_copies = 'true'; END; @@ -10,12 +24,16 @@ FOREACH copy_info IN ctx.copies; LAST; END; END; -%] +-%] [%- IF has_copies; %] + [% IF serial_holdings -%] + + [%- ELSE -%] + [%- END %] [%- IF has_parts == 'true' %] @@ -25,6 +43,8 @@ END; [%- IF ctx.is_staff %] + [%- END %] + [%- IF ctx.is_staff OR serial_holdings %] [%- END %] @@ -33,7 +53,7 @@ END; [%- last_cn = 0; - FOR copy_info IN ctx.copies; + FOR copy_info IN copies; callnum = copy_info.call_number_label; NEXT IF callnum == '##URI##'; @@ -48,13 +68,16 @@ END; END; -%] - + [%- ELSE %] - + [% END %] + [%- IF has_parts == 'true' %] [%- END %] @@ -73,13 +96,18 @@ END; ctx.parse_datetime(copy_info.create_date), DATE_FORMAT ) %] + [% END # is_staff %] + [% IF ctx.is_staff OR serial_holdings %] @@ -105,7 +145,7 @@ END; [%- END %] - [%- IF ctx.copy_offset > 0; + [%- IF ctx.copy_offset > 0 AND NOT serial_holdings; new_offset = ctx.copy_offset - ctx.copy_limit; IF new_offset < 0; new_offset = 0; END %] [%- END %] - [%- IF ctx.copies.size >= ctx.copy_limit %] + [%- IF copies.size >= ctx.copy_limit AND NOT serial_holdings %] [%- END %] + [% IF NOT serial_holdings -%] + [%- END %]
[% l("Issue Label") %][% l("Location") %][% l("Call Number") %][% l("Part") %][% l("Age Hold Protection") %] [% l("Create Date") %][% l("Holdable?") %][% l("Status") %]
+ [%- IF serial_holdings %] + [%- copy_info.holding_label | html; -%] + [%- org_name = ctx.get_aou(copy_info.circ_lib).name; org_name | html -%] - [% callnum | html %] [% IF ctx.get_org_setting(ctx.search_ou, 'sms.enable') == 1 %](Text)[% END %][% callnum | html %] [% IF ctx.get_org_setting(CGI.param('loc') OR ctx.aou_tree.id, 'sms.enable') == 1 %](Text)[% END %][% copy_info.part_label | html %][% # Show copy/volume hold links to staff (without # checking whether they have permissions to do those). - overall_holdable = (copy_info.holdable == 't' AND + overall_holdable = ( + copy_info.holdable == 't' AND copy_info.location_holdable == 't' AND copy_info.status_holdable == 't'); IF overall_holdable; - l("Place on"); %] + l("Place on"); + IF ctx.is_staff; + %] [% l("copy") %] [%- IF copy_info.call_number != last_cn; @@ -88,6 +116,18 @@ END; [% l("volume") %] [%- END; + IF serial_holdings; + l(" / "); + END; + END; + IF serial_holdings; + %] + [% l("issue") %][%- + END; ELSE; l("No"); END %]
@@ -113,17 +153,18 @@ END; l('Previous [_1]', ctx.copy_offset - new_offset) %] [% l('Next [_1]', ctx.copy_limit) %] »
[%- more_copies_limit = 50 %] [%# TODO: config %] - [%- IF ctx.copy_limit != more_copies_limit AND ctx.copies.size >= ctx.copy_limit %] + [%- IF ctx.copy_limit != more_copies_limit AND copies.size >= ctx.copy_limit %]
[% l('Show more copies') %] @@ -136,6 +177,7 @@ END; [%- END %]
[% END; %] diff --git a/Open-ILS/src/templates/opac/parts/record/issues-db.tt2 b/Open-ILS/src/templates/opac/parts/record/issues-db.tt2 new file mode 100644 index 0000000000..6e9179ca9f --- /dev/null +++ b/Open-ILS/src/templates/opac/parts/record/issues-db.tt2 @@ -0,0 +1,161 @@ +[%- +expand_path = CGI.param('sepath') || []; +expand_path = expand_path.list; # sic + +seoffset_list = CGI.param('seoffset') || []; +seoffset_list = seoffset_list.list; # sic + +IF expand_path.size == 0 AND seoffset_list.size == 0; + seoffset_list = [0,0]; # compensate for $auto_expand_first; see ML +END; + +selimit = CGI.param('selimit') || 10; +ght_sepath = []; +ght_depth = 0; + +VIEW grouped_holding_tree; + BLOCK list; + '
'; + prev_seoffset_list = seoffset_list.slice(0, ght_depth); + next_seoffset_list = seoffset_list.slice(0, ght_depth); + + prev_seoffset_list.$ght_depth = prev_seoffset_list.$ght_depth - selimit; + IF prev_seoffset_list.$ght_depth < 0; + prev_seoffset_list.$ght_depth = 0; + END; + + next_seoffset_list.$ght_depth = next_seoffset_list.$ght_depth + selimit; + IF item.0.units.size; + INCLUDE "opac/parts/record/copy_table.tt2" serial_holdings=item; + "
"; + "
"; + ELSE; + FOREACH node IN item; + IF NOT node.label; + has_more = 1; + LAST; + END; + + IF node.value; + ght_sepath.push(node.value); + new_seoffsets = seoffset_list.slice(0, ght_depth); + new_seoffsets.push(0); + + expand_link = mkurl( + '', {'sepath' => ght_sepath, 'seoffset' => new_seoffsets}, + 0, 'issues' + ); + + collapse_sepath = ght_sepath.slice(0, -2); + IF collapse_sepath.size == 0; + collapse_clear_params = ['sepath']; + ELSE; + collapse_clear_params = 0; + END; + + collapse_link = mkurl( + '', { + 'sepath' => collapse_sepath, + 'seoffset' => new_seoffsets.slice(0, -2) + }, collapse_clear_params, 'issues' + ); + + "
"; + IF node.children.size; + # TODO: make images or figure out a CSS trick or + # something. I doubt we can count on all OPAC clients + # having adequate fonts to cover these Unicode glyphs. + "▼ "; node.label; "
"; + + IF node.children.size; + ght_depth = ght_depth + 1; + view.print(node.children); + ght_depth = ght_depth - 1; + END; + + waste = ght_sepath.pop; + ELSE; + "
"; node.label; "
"; + # XXX Hold placement link here? Maybe not if no units. + END; + END; + + to_clear = 0; + new_sepath_end = ght_depth - 1; + IF new_sepath_end < 0; + to_clear = ['sepath']; + new_sepath = []; + ELSE; + new_sepath = expand_path.slice(0, ght_depth - 1); + END; + + IF has_more; + '« '; l('Earlier holdings'); ''; + END; + IF seoffset_list.$ght_depth > 0; + ''; l('Later holdings'); ' »  '; + END; + ''; + END; + END; +END; + +VIEW holding_summary_tree; + BLOCK hash; + '
'; + ctx.get_aou(item.org_unit).name; "
"; + FOREACH summary IN item.holding_summaries; + IF summary.holdings; + twisty = '▼ '; + link = mkurl( + '', {}, + ['sid','stype','selimit','sepath','seoffset'], 'issues' + ); + link_title = l('Collapse'); + ELSE; + twisty = '► '; + link = mkurl( + '', {sid => summary.id, stype => summary.summary_type}, + ['selimit','sepath','seoffset'], 'issues' + ); + link_title = l('Expand'); + END; + ''; twisty; + ''; + summary.generated_coverage.join(", "); + '
'; + IF summary.holdings; + grouped_holding_tree.print(summary.holdings); + END; + END; + FOREACH child IN item.children; + view.print(child); + END; + '
'; + END; +END %] +
+ [% holding_summary_tree.print(ctx.holding_summary_tree) %] +
+
+ [% slimit = CGI.param('slimit') || 10; + soffset = CGI.param('soffset') || 0; + soffset_prev = soffset - slimit; + IF soffset_prev < 0; soffset_prev = 0; END; + soffset_next = soffset + slimit; + %] + [% IF soffset > 0 %] + soffset_next}, ['sid','stype','sepath','selimit','seoffset'], 'issues') %]">[% l('Next') %] + [% END %] +
diff --git a/Open-ILS/src/templates/opac/parts/record/issues-mfhd.tt2 b/Open-ILS/src/templates/opac/parts/record/issues-mfhd.tt2 new file mode 100644 index 0000000000..15b9ab9d3c --- /dev/null +++ b/Open-ILS/src/templates/opac/parts/record/issues-mfhd.tt2 @@ -0,0 +1,40 @@ +[% IF ctx.mfhd_summaries.size; %] +
+ +[% + mfhd = { + basic_holdings = l('Volumes'), + basic_holdings_add = l('Additional Volume Information'), + supplement_holdings = l('Supplements'), + supplement_holdings_add = l('Additional Supplement Information'), + index_holdings = l('Indexes'), + index_holdings_add = l('Additional Index Information'), + online = l('Online'), + missing = l('Missing'), + incomplete = l('Incomplete'), + }; + + FOREACH serial IN ctx.mfhd_summaries; +%] + + + +[% + FOREACH type IN mfhd.keys; + NEXT UNLESS serial.$type.size; +%] + + + + + [% END %] + + + + [% END %] +
[% l('Holdings summary ([_1])', serial.location) %]
[% mfhd.$type %][% + FOR thing IN serial.$type; + thing.join(", "); + END %]
+
+[% END %] diff --git a/Open-ILS/src/templates/opac/parts/record/issues.tt2 b/Open-ILS/src/templates/opac/parts/record/issues.tt2 index f346587a80..585cee6a04 100644 --- a/Open-ILS/src/templates/opac/parts/record/issues.tt2 +++ b/Open-ILS/src/templates/opac/parts/record/issues.tt2 @@ -1,67 +1,4 @@
-[% -base_expando = ctx.full_path _ "?expand=issues"; -FOREACH type IN ctx.holding_summaries.keys; - NEXT UNLESS ctx.holding_summaries.$type.size; - expanded = CGI.param('expand_holding_type') == type; %] -
- [[% expanded ? '-' : '+' %]] - [% ctx.holding_summaries.$type.join(", ") %] - [% IF expanded %] - - [% FOR blob IN ctx.expanded_holdings %] - - - [% IF blob.has_units %] - - [% END %] - - [% END %] -
[% blob.issuance.label | html %] - [% l("Place Hold") %] -
- [% END %] -
-[% END %] -[% IF ctx.mfhd_summaries.size; %] -
- -[% - mfhd = { - basic_holdings = l('Volumes'), - basic_holdings_add = l('Additional Volume Information'), - supplement_holdings = l('Supplements'), - supplement_holdings_add = l('Additional Supplement Information'), - index_holdings = l('Indexes'), - index_holdings_add = l('Additional Index Information'), - online = l('Online'), - missing = l('Missing'), - incomplete = l('Incomplete'), - }; - - FOREACH serial IN ctx.mfhd_summaries; -%] - - - -[% - FOREACH type IN mfhd.keys; - NEXT UNLESS serial.$type.size; -%] - - - - - [% END %] - - - - [% END %] -
[% l('Holdings summary ([_1])', serial.location) %]
[% mfhd.$type %][% - FOR thing IN serial.$type; - thing.join(", "); - END %]
-
-[% END %] + [% INCLUDE 'opac/parts/record/issues-db.tt2' # "new" serials holdings %] + [% INCLUDE 'opac/parts/record/issues-mfhd.tt2' # mfhd-based "classic" serials %]
diff --git a/Open-ILS/src/templates/opac/parts/record/summary.tt2 b/Open-ILS/src/templates/opac/parts/record/summary.tt2 index 69a9f389a1..062bf2f1dd 100644 --- a/Open-ILS/src/templates/opac/parts/record/summary.tt2 +++ b/Open-ILS/src/templates/opac/parts/record/summary.tt2 @@ -106,7 +106,7 @@ IF num_uris > 0; ctx.record_hold_count, ctx.copy_summary.0.count) %]

-[%- INCLUDE "opac/parts/record/copy_table.tt2" %] +[%- INCLUDE "opac/parts/record/copy_table.tt2" copies=ctx.copies %] [%- END %] diff --git a/Open-ILS/src/templates/serial/subscription.tt2 b/Open-ILS/src/templates/serial/subscription.tt2 index 47eb714f9e..d4bc78977f 100644 --- a/Open-ILS/src/templates/serial/subscription.tt2 +++ b/Open-ILS/src/templates/serial/subscription.tt2 @@ -75,6 +75,40 @@ new dijit.form.TextBox({ "disabled": true, "value": sub_id }); + + var _display_grouping_store = new dojo.data.ItemFileReadStore({ + "data": { + "identifier": "display_grouping", + "label": "label", + "items": [ + {"display_grouping": "chron", + "label": "Chronology"}, + {"display_grouping": "enum", + "label": "Enumeration"} + ] + } + }); + + var settings = fieldmapper.aou.fetchOrgSettingBatch( + openils.User.user.ws_ou(), + ["serial.default_display_grouping"] + ); + var default_display_grouping = "chron"; + + if (settings && settings["serial.default_display_grouping"]) { + default_display_grouping = + settings["serial.default_display_grouping"].value; + } + + dist_grid.overrideEditWidgets.display_grouping = + new dijit.form.FilteringSelect({ + "store": _display_grouping_store, + "searchAttr": "label", + "name": "display_grouping" + }); + dist_grid.overrideEditWidgets.display_grouping.shove = { + "create": default_display_grouping + }; } [% INCLUDE "serial/subscription/distribution.tt2" %] diff --git a/Open-ILS/web/css/skin/default/opac/style.css b/Open-ILS/web/css/skin/default/opac/style.css index f36511c64b..62b677fc1c 100644 --- a/Open-ILS/web/css/skin/default/opac/style.css +++ b/Open-ILS/web/css/skin/default/opac/style.css @@ -1422,3 +1422,6 @@ a.preflib_change { line-height: normal; text-decoration: none; } +.rdetail-holding-group { margin-left: 1.5em; } +.rdetail-holding-group span { margin-left: 1.5em; } +.rdetail-holding-group .paging { margin-left: 1.5em; } -- 2.11.0