]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm
Merge branch 'master' of git.evergreen-ils.org:Evergreen-DocBook into doc_consolidati...
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Serial / OPAC.pm
1 package OpenILS::Application::Serial::OPAC;
2
3 # This package contains methods for open-ils.serial that present data suitable
4 # for OPAC display.
5
6 use base qw/OpenILS::Application/;
7 use strict;
8 use warnings;
9
10 # All of the packages we might 'use' are already imported in
11 # OpenILS::Application::Serial.  Only those that export symbols
12 # need to be mentioned explicitly here.
13
14 use OpenSRF::Utils::Logger qw/:logger/;
15 use OpenILS::Utils::CStoreEditor q/:funcs/;
16
17 my $U = "OpenILS::Application::AppUtils";
18
19 my %MFHD_SUMMARIZED_SUBFIELDS = (
20    enum => [ split //, "abcdef" ],   # $g and $h intentionally omitted for now
21    chron => [ split //, "ijklm" ]
22 );
23
24 # This is a helper for scoped_holding_summary_tree_for_bib() a little further down
25
26 sub _place_org_node {
27     my ($node, $tree, $org_tree) = @_;
28
29     my @ancestry = reverse @{ $U->get_org_ancestors($node->{org_unit}, 1) };
30     shift @ancestry;    # discard current org_unit
31
32     foreach (@ancestry) {  # in leaf-to-root order
33         my $graft_point = _find_ou_in_holdings_tree($tree, $_);
34
35         if ($graft_point) {
36             push @{$graft_point->{children}}, $node;
37             return;
38         } else {
39             $node = {
40                 org_unit => $_,
41                 holding_summaries => [],
42                 children => [$node]
43             }
44         }
45     }
46
47     # If we reach this point, we got all the way to the top of the org tree
48     # without finding corresponding nodes in $tree (holdings tree), so the
49     # latter must be empty, and we need to make $tree just contain what $node
50     # contains.
51
52     %$tree = %$node;
53 }
54
55 # This is a helper for scoped_holding_summary_tree_for_bib() a little further down
56
57 sub _find_ou_in_holdings_tree {
58     my ($tree, $id) = @_;
59
60     return $tree if $tree->{org_unit} eq $id;
61     if (ref $tree->{children}) {
62         foreach (@{$tree->{children}}) {
63             my $maybe = _find_ou_in_holdings_tree($_, $id);
64             return $maybe if $maybe;
65         }
66     }
67
68     return;
69 }
70
71 sub scoped_holding_summary_tree_for_bib {
72     my (
73         $self, $client, $bib, $org_unit, $depth, $limit, $offset, $ascending
74     ) = @_;
75
76     my $org_tree = $U->get_org_tree;    # caches
77
78     $org_unit ||= $org_tree->id;
79     $depth ||= 0;
80     $limit ||= 10;
81     $offset ||= 0;
82
83     my $e = new_editor;
84
85     # What we want to know from this query is essentially the set of
86     # holdings related to a given bib and the org units that have said
87     # holdings.
88
89     # For this we would only need sasum, sdist and ssub, but
90     # because we also need to be able to page (and therefore must sort) the
91     # results we get, we need reasonable columns on which to do the sorting.
92     # So for that we join sitem (via sstr) so we can sort on the maximum
93     # date_expected (which is basically the issue pub date) for items that
94     # have been received.  That maximum date_expected is actually the second
95     # sort key, however.  The first is the holding lib's position in a
96     # depth-first representation of the org tree (if you think about it,
97     # paging through holdings held at diverse points in the tree only makes
98     # sense if you do it this way).
99
100     my $rows = $e->json_query({
101         select => {
102             sasum => [qw/summary_type id generated_coverage/],
103             sdist => ["holding_lib"],
104             sitem => [
105                 {column => "date_expected", transform => "max", aggregate => 1}
106             ]
107         },
108         from => {
109             sasum => {
110                 sdist => {
111                     join => {
112                         ssub => {},
113                         sstr => {
114                             join => {sitem => {}}
115                         },
116                     }
117                 }
118             }
119         },
120         where => {
121             "+sdist" => {
122                 holding_lib =>
123                     $U->get_org_descendants(int($org_unit), int($depth))
124             },
125             "+ssub" => {record_entry => int($bib)},
126             "+sitem" => {date_received => {"!=" => undef}}
127         },
128         limit => int($limit) + 1, # see comment below on "limit trick"
129         offset => int($offset),
130         order_by => [
131             {
132                 class => "sdist",
133                 field => "holding_lib",
134                 transform => "actor.org_unit_simple_path",
135                 params => [$org_tree->id]
136             },
137             {
138                 class => "sitem",
139                 field => "date_expected",
140                 transform => "max", # to match select clause
141                 direction => ($ascending ? "ASC" : "DESC")
142             }
143         ],
144     }) or return $e->die_event;
145
146     $e->disconnect;
147
148     # Now we build a tree out of our result set.
149     my $result = {};
150
151     # Use our "limit trick" from above to cheaply determine whether there's
152     # another page of results, for the UI's benefit.  Put $more into the
153     # result hash at the very end.
154     my $more = 0;
155     if (scalar(@$rows) > int($limit)) {
156         $more = 1;
157         pop @$rows;
158     }
159
160     foreach my $row (@$rows) {
161         my $org_node_needs_placed = 0;
162         my $org_node =
163             _find_ou_in_holdings_tree($result, $row->{holding_lib});
164
165         if (not $org_node) {
166             $org_node_needs_placed = 1;
167             $org_node = {
168                 org_unit => $row->{holding_lib},
169                 holding_summaries => [],
170                 children => []
171             };
172         }
173
174         # Make a very simple object for a single holding summary.
175         # generated_coverage is stored as JSON, and here we can unpack it.
176         my $summary = {
177             id => $row->{id},
178             summary_type => $row->{summary_type},
179             generated_coverage =>
180                 OpenSRF::Utils::JSON->JSON2perl($row->{generated_coverage})
181         };
182
183         push @{$org_node->{holding_summaries}}, $summary;
184
185         if ($org_node_needs_placed) {
186             _place_org_node($org_node, $result, $org_tree);
187         }
188     }
189
190     $result->{more} = $more;
191     return $result;
192 }
193
194 __PACKAGE__->register_method(
195     method    => "scoped_holding_summary_tree_for_bib",
196     api_name  => "open-ils.serial.holding_summary_tree.by_bib",
197     api_level => 1,
198     argc      => 6,
199     signature => {
200         desc   => 'Return a set of holding summaries organized into a tree
201         of nodes that look like:
202             {org_unit:<id>, holding_summaries:[], children:[]}
203
204         The root node has an extra key: "more". Its value is 1 if there
205         are more pages (in the limit/offset sense) of results that the caller
206         could potentially fetch.
207
208         All arguments except the first (bibid) are optional.
209         ',
210         params => [
211             {   name => "bibid",
212                 desc => "ID of the bre to which holdings belong",
213                 type => "number"
214             },
215             { name => "org_unit", type => "number" },
216             { name => "depth (default 0)", type => "number" },
217             { name => "limit (default 10)", type => "number" },
218             { name => "offset (default 0)", type => "number" },
219             { name => "ascending (default false)", type => "boolean" },
220         ]
221     }
222 );
223
224 # This is a helper for grouped_holdings_for_summary() later.
225 sub _label_holding_level {
226     my ($pattern_field, $subfield, $value, $mfhd_cache) = @_;
227
228     # This is naïve, in that a-f are sometimes chron fields and not enum.
229     # OpenILS::Utils::MFHD understands that, but so far I don't think our
230     # interfaces do.
231
232     my $cache_key = $subfield . $value;
233
234     if (not exists $mfhd_cache->{$cache_key}) {
235         my $link_id = (split(/\./, $pattern_field->subfield('8')))[0];
236         my $fake_holding = new MFHD::Holding(
237             1,
238             new MARC::Field('863', '4', '1', '8', "$link_id.1"),
239             new MFHD::Caption($pattern_field->clone)
240         );
241
242         if ($subfield ge 'i') { # chron
243             $mfhd_cache->{$cache_key} = $fake_holding->format_single_chron(
244                 {$subfield => $value}, $subfield, 1, 1
245             );
246         } else {                # enum
247             $mfhd_cache->{$cache_key} = $fake_holding->format_single_enum(
248                 {$subfield => $value}, $subfield, 1
249             );
250         }
251     }
252
253     return $mfhd_cache->{$cache_key};
254 }
255
256 # This is a helper for grouped_holdings_for_summary() later.
257 sub _get_deepest_holding_level {
258     my ($display_grouping, $pattern_field) = @_;
259
260     my @present = grep { $pattern_field->subfield($_) } @{
261         $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping}
262     };
263
264     return pop @present;
265 }
266
267 # This is a helper for grouped_holdings_for_summary() later.
268 sub _opac_visible_unit_data {
269     my ($issuance_id_list, $dist_id, $staff, $e) = @_;
270
271     return {} unless @$issuance_id_list;
272
273     my $rows = $e->json_query(
274         $U->basic_opac_copy_query(
275             undef, $issuance_id_list, $dist_id,
276             1000, 0,    # XXX no mechanism for users to page at this level yet
277             $staff
278         )
279     ) or return $e->die_event;
280
281     my $results = {};
282
283     # Take the list of rows returned from json_query() and sort results into
284     # several smaller lists stored in a hash keyed by issuance ID.
285     foreach my $row (@$rows) {
286         $results->{$row->{issuance}} = [] unless
287             exists $results->{$row->{issuance}};
288         push @{ $results->{$row->{issuance}} }, $row;
289     }
290
291     return $results;
292 }
293
294 # This is a helper for grouped_holdings_for_summary() later.
295 sub _make_grouped_holding_node {
296     my (
297         $row, $subfield, $deepest_level, $pattern_field,
298         $unit_data, $mfhd_cache
299     ) = @_;
300
301     return {
302         $subfield eq $deepest_level ? (
303             label => $row->{label},
304             holding => $row->{id},
305             ($unit_data ? (units => ($unit_data->{$row->{id}} || [])) : ())
306         ) : (
307             value => $row->{value},
308             label => _label_holding_level(
309                 $pattern_field, $subfield, $row->{value}, $mfhd_cache
310             )
311         )
312     };
313 }
314
315 # This is a helper for grouped_holdings_for_summary() later.
316 sub _make_single_level_grouped_holding_query {
317     my (
318         $subfield, $deepest_level, $summary_hint, $summary_id,
319         $subfield_joins, $subfield_where_clauses,
320         $limit, $offsets
321     ) = @_;
322
323     return {
324         select => {
325             sstr => ["distribution"],
326             "smhc_$subfield" => ["value"], (
327                 $subfield eq $deepest_level ?
328                     (siss => [qw/id label date_published/]) : ()
329             )
330         },
331         from => {
332             $summary_hint => {
333                 sdist => {
334                     join => {
335                         sstr => {
336                             join => {
337                                 sitem => {
338                                     join => {
339                                         siss => {
340                                             join => {%$subfield_joins}
341                                         }
342                                     }
343                                 }
344                             }
345                         }
346                     }
347                 }
348             }
349         },
350         where => {
351             "+$summary_hint" => {id => $summary_id},
352             "+sitem" => {date_received => {"!=" => undef}},
353             %$subfield_where_clauses
354         },
355         distinct => 1,  # sic, this goes here in json_query
356         limit => int($limit) + 1,
357         offset => int(shift(@$offsets)),
358         order_by => {
359             "smhc_$subfield" => {
360                 "value" => {
361                     direction => ($subfield eq $deepest_level ? "asc" : "desc")
362                 }
363             }
364         }
365     };
366 }
367
368 sub grouped_holdings_for_summary {
369     my (
370         $self, $client, $summary_type, $summary_id,
371         $expand_path, $limit, $offsets, $auto_expand_first, $with_units
372     ) = @_;
373
374     # Validate input or set defaults.
375     ($summary_type .= "") =~ s/[^\w]//g;
376     $summary_id = int($summary_id);
377     $expand_path ||= [];
378     $limit ||= 12;
379     $limit = 12 if $limit < 1;
380     $offsets ||= [0];
381
382     foreach ($expand_path, $offsets) {
383         if (ref $_ ne 'ARRAY') {
384             return new OpenILS::Event(
385                 "BAD_PARAMS", note =>
386                     "'expand_path' and 'offsets' arguments must be arrays"
387             );
388         }
389     }
390
391     if (scalar(@$offsets) != scalar(@$expand_path) + 1) {
392         return new OpenILS::Event(
393             "BAD_PARAMS", note =>
394                 "'offsets' array must be one element longer than 'expand_path'"
395         );
396     }
397
398     # Get the class hint for whichever type of summary we're expanding.
399     my $fmclass = "Fieldmapper::serial::${summary_type}_summary";
400     my $summary_hint = $Fieldmapper::fieldmap->{$fmclass}{hint} or
401         return new OpenILS::Event("BAD_PARAMS", note => "summary_type");
402
403     my $e = new_editor;
404
405     # First, get display grouping for requested summary (either chron or enum)
406     # and the pattern code. Even though we have to JOIN through sitem to get
407     # pattern_code from scap, we don't actually care about specific items yet.
408     my $row = $e->json_query({
409         select => {sdist => ["display_grouping"], scap => ["pattern_code"]},
410         from => {
411             $summary_hint => {
412                 sdist => {
413                     join => {
414                         sstr => {
415                             join => {
416                                 sitem => {
417                                     join => {
418                                         siss => {
419                                             join => {scap => {}}
420                                         }
421                                     }
422                                 }
423                             }
424                         }
425                     }
426                 }
427             }
428         },
429         where => {
430             "+$summary_hint" => {id => $summary_id},
431             "+sitem" => {date_received => {"!=" => undef}}
432         },
433         limit => 1
434     }) or return $e->die_event;
435
436     # Summaries without attached holdings constitute bad data, not benign
437     # empty result sets.
438     return new OpenILS::Event(
439         "BAD_PARAMS",
440         note => "Summary #$summary_id not found, or no holdings attached"
441     ) unless @$row;
442
443     # Unless data has been disarranged, all holdings grouped together under
444     # the same summary should have the same pattern code, so we can take any
445     # result from the set we just got.
446     my $pattern_field;
447     eval {
448         $pattern_field = new MARC::Field(
449             "853", # irrelevant for our purposes
450             @{ OpenSRF::Utils::JSON->JSON2perl($row->[0]->{pattern_code}) }
451         );
452     };
453     if ($@) {
454         return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $@);
455     }
456
457     # And now we know which subfields we will care about from
458     # serial.materialized_holding_code.
459     my $display_grouping = $row->[0]->{display_grouping};
460
461     # This will tell us when to stop grouping and start showing actual
462     # holdings.
463     my $deepest_level =
464         _get_deepest_holding_level($display_grouping, $pattern_field);
465     if (not defined $deepest_level) {
466         # corrupt pattern code
467         my $msg = "couldn't determine deepest holding level for " .
468             "$summary_type summary #$summary_id";
469         $logger->warn($msg);
470         return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $msg);
471     }
472
473     my @subfields = @{ $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping} };
474
475     # We look for holdings grouped at the top level once no matter what,
476     # then we'll look deeper with additional queries for every element of
477     # $expand_path later.
478     # Below we define parts of the SELECT and JOIN clauses that we'll
479     # potentially reuse if $expand_path has elements.
480
481     my $subfield = shift @subfields;
482     my %subfield_joins = ("smhc_$subfield" => {class => "smhc"});
483     my %subfield_where_clauses = ("+smhc_$subfield" => {subfield => $subfield});
484
485     # Now get the top level of holdings.
486     my $top = $e->json_query(
487         _make_single_level_grouped_holding_query(
488             $subfield, $deepest_level, $summary_hint, $summary_id,
489             \%subfield_joins, \%subfield_where_clauses,
490             $limit, $offsets
491         )
492     ) or return $e->die_event;
493
494     # Deal with the extra row, if present, that tells are there are more pages
495     # of results.
496     my $top_more = 0;
497     if (scalar(@$top) > int($limit)) {
498         $top_more = 1;
499         pop @$top;
500     }
501
502     # Distribution is the same for all rows anyway, but we may need it for a
503     # copy query later.
504     my $dist_id = @$top ? $top->[0]->{distribution} : undef;
505
506     # This will help us avoid certain repetitive calculations. Examine
507     # _label_holding_level() to see what I mean.
508     my $mfhd_cache = {};
509
510     # Prepare related unit data if appropriate.
511     my $unit_data;
512
513     if ($with_units and $subfield eq $deepest_level) {
514         $unit_data = _opac_visible_unit_data(
515             [map { $_->{id} } @$top], $dist_id, $with_units > 1, $e
516         );
517         return $unit_data if defined $U->event_code($unit_data);
518     }
519
520     # Make the tree we have so far.
521     my $tree = [
522         { display_grouping => $display_grouping,
523             caption => $pattern_field->subfield($subfield) },
524         map(
525             _make_grouped_holding_node(
526                 $_, $subfield, $deepest_level, $pattern_field,
527                 $unit_data, $mfhd_cache
528             ),
529             @$top
530         ),
531         ($top_more ? undef : ())
532     ];
533
534     # We'll need a parent reference at each level as we descend.
535     my $parent = $tree;
536
537     # Will we be trying magic auto-expansion of the first top-level grouping?
538     if ($auto_expand_first and @$tree and not @$expand_path) {
539         $expand_path = [$tree->[1]->{value}];
540         $offsets = [0];
541     }
542
543     # Ok, that got us the top level, with nothing expanded. Now we loop through
544     # the elements of @$expand_path, issuing similar queries to get us deeper
545     # groupings and even actual specific holdings.
546     foreach my $value (@$expand_path) {
547         my $prev_subfield = $subfield;
548         $subfield = shift @subfields;
549
550         # This wad of JOINs is additive over each iteration.
551         $subfield_joins{"smhc_$subfield"} = {class => "smhc"};
552
553         # The WHERE clauses also change and grow each time.
554         $subfield_where_clauses{"+smhc_$prev_subfield"}->{value} = $value;
555         $subfield_where_clauses{"+smhc_$subfield"}->{subfield} = $subfield;
556
557         my $level = $e->json_query(
558             _make_single_level_grouped_holding_query(
559                 $subfield, $deepest_level, $summary_hint, $summary_id,
560                 \%subfield_joins, \%subfield_where_clauses,
561                 $limit, $offsets
562             )
563         ) or return $e->die_event;
564
565         return $tree unless @$level;
566
567         # Deal with the extra row, if present, that tells are there are more
568         # pages of results.
569         my $level_more = 0;
570         if (scalar(@$level) > int($limit)) {
571             $level_more = 1;
572             pop @$level;
573         }
574
575         # Find attachment point for our results.
576         my ($point) = grep { ref $_ and $_->{value} eq $value } @$parent;
577
578         # Prepare related unit data if appropriate.
579         if ($with_units and $subfield eq $deepest_level) {
580             $unit_data = _opac_visible_unit_data(
581                 [map { $_->{id} } @$level], $dist_id, $with_units > 1, $e
582             );
583             return $unit_data if defined $U->event_code($unit_data);
584         }
585
586         # Set parent for the next iteration.
587         $parent = $point->{children} = [
588             { display_grouping => $display_grouping,
589                 caption => $pattern_field->subfield($subfield) },
590             map(
591                 _make_grouped_holding_node(
592                     $_, $subfield, $deepest_level, $pattern_field,
593                     $unit_data, $mfhd_cache
594                 ),
595                 @$level
596             ),
597             ($level_more ? undef : ())
598         ];
599
600         last if $subfield eq $deepest_level;
601     }
602
603     return $tree;
604 }
605
606 __PACKAGE__->register_method(
607     method    => "grouped_holdings_for_summary",
608     api_name  => "open-ils.serial.holdings.grouped_by_summary",
609     api_level => 1,
610     argc      => 7,
611     signature => {
612         desc   => q/Return a tree of holdings associated with a given summary
613         grouped by all but the last of either chron or enum units./,
614         params => [
615             { name => "summary_type", type => "string" },
616             { name => "summary_id", type => "number" },
617             { name => "expand_path", type => "array",
618                 desc => "In root-to-leaf order, the values of the nodes along the axis you want to expand" },
619             { name => "limit (default 12)", type => "number" },
620             { name => "offsets", type => "array", desc =>
621                 "This must be exactly one element longer than expand_path" },
622             { name => "auto_expand_first", type => "boolean", desc =>
623                 "Only if expand_path is empty, automatically expand first top-level grouping" },
624             { name => "with_units", type => "number", desc => q/
625                 If true at all, for each holding, if there are associated units,
626                 add some information about them to the result tree. These units
627                 will be filtered by OPAC visibility unless you provide a value
628                 greater than 1.
629
630                 IOW:
631                     0 = no units,
632                     1 = opac visible units,
633                     2 = all units (i.e. staff view)
634                 / }
635         ]
636     }
637 );
638
639 1;