]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor.pm
LP#1749475: OPAC email/print record improvements
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Trigger / Reactor.pm
1 package OpenILS::Application::Trigger::Reactor;
2 use strict; use warnings;
3 use Encode qw/ encode /;
4 use Template;
5 use DateTime;
6 use DateTime::Format::ISO8601;
7 use Unicode::Normalize;
8 use XML::LibXML;
9 use OpenILS::Utils::DateTime qw/:datetime/;
10 use OpenSRF::Utils::Logger qw(:logger);
11 use OpenSRF::Utils::JSON;
12 use OpenILS::Application::AppUtils;
13 use OpenILS::Utils::CStoreEditor qw/:funcs/;
14 my $U = 'OpenILS::Application::AppUtils';
15
16 sub fourty_two { return 42 }
17 sub NOOP_True  { return  1 }
18 sub NOOP_False { return  0 }
19
20
21 # To be used in two places within $_TT_helpers.  Without putting the code out
22 # here, we can't really reuse it within that structure.
23 sub get_li_attr {
24     my $name = shift or return;     # the first arg is always the name
25     my ($type, $attr) = (scalar(@_) == 1) ? (undef, $_[0]) : @_;
26     # if the next is the last, it's the attributes, otherwise type
27     # use Data::Dumper; $logger->warn("get_li_attr: " . Dumper($attr));
28     ($name and @$attr) or return;
29     my $length;
30     $name =~ s/^(\D+)_(\d+)$/$1/ and $length = $2;
31     foreach (@$attr) {
32         $_->attr_name eq $name or next;
33         next if $length and $length != length($_->attr_value);
34         return $_->attr_value if (! $type) or $type eq $_->attr_type;
35     }
36     return;
37 }
38
39 # helper functions inserted into the TT environment
40 my $_TT_helpers; # define first so one helper can use another
41 $_TT_helpers = {
42
43     # turns a date into something TT can understand
44     format_date => sub {
45         my $date = shift;
46         $date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($date));
47         return sprintf(
48             "%0.2d:%0.2d:%0.2d %0.2d-%0.2d-%0.4d",
49             $date->hour,
50             $date->minute,
51             $date->second,
52             $date->day,
53             $date->month,
54             $date->year
55         );
56     },
57
58     # escapes a string for inclusion in an XML document.  escapes &, <, and > characters
59     escape_xml => sub {
60         my $str = shift;
61         $str =~ s/&/&amp;/sog;
62         $str =~ s/</&lt;/sog;
63         $str =~ s/>/&gt;/sog;
64         return $str;
65     },
66
67     escape_json => sub {
68         my $str = shift;
69         $str =~ s/([\x{0080}-\x{fffd}])/sprintf('\u%0.4x',ord($1))/sgoe;
70         return $str;
71     },
72
73     # encode email headers in UTF-8, per RFC2231
74     # now a no-op as we automatically encode the headers in the SendEmail
75     # reactor, but we need to leave this here to avoid breaking templates
76     # that might have once used it
77     escape_email_header => sub {
78         my $str = shift;
79         return $str;
80     },
81
82     # strip non-ASCII characters after splitting base characters and diacritics
83     # least common denominator for EDIFACT messages using the UNOB character set
84     force_jedi_unob => sub {
85         my $str = shift;
86         $str = NFD($str);
87         $str =~ s/[\x{0080}-\x{fffd}]//g;
88         return $str;
89     },
90
91     # returns the calculated user locale
92     get_user_locale => sub { 
93         my $user_id = shift;
94         return $U->get_user_locale($user_id);
95     },
96
97     # returns the calculated copy price
98     get_copy_price => sub {
99         my $copy_id = shift;
100         return $U->get_copy_price(new_editor(xact=>1), $copy_id);
101     },
102
103     get_org_unit => sub {
104         my $org_id = shift;
105         return $org_id if ref $org_id;
106         return new_editor()->retrieve_actor_org_unit($org_id);
107     },
108
109     get_org_unit_ancestor_at_depth => sub {
110       my $org_id = shift;
111       my $depth = shift;
112       $org_id = $org_id->id if ref $org_id;
113       return new_editor()->retrieve_actor_org_unit($U->org_unit_ancestor_at_depth($org_id, $depth));
114     }, 
115
116     # given a copy, returns the title and author in a hash
117     get_copy_bib_basics => sub {
118         my $copy_id = shift;
119         my $copy = new_editor(xact=>1)->retrieve_asset_copy([
120             $copy_id,
121             {
122                 flesh => 2,
123                 flesh_fields => {
124                     acp => ['call_number'],
125                     acn => ['record']
126                 }
127             }
128         ]);
129         if($copy->call_number->id == -1) {
130             return {
131                 title  => $copy->dummy_title,
132                 author => $copy->dummy_author,
133             };
134         } else {
135             my $mvr = $U->record_to_mvr($copy->call_number->record);
136             return {
137                 title  => $mvr->title,
138                 author => $mvr->author
139             };
140         }
141     },
142
143     # given a call number, returns the copy location with the most copies
144     get_most_populous_location => sub {
145         my $acn_id = shift;
146
147         # FIXME - there's probably a more efficient way to do this with json_query/SQL
148         my $call_number = new_editor(xact=>1)->retrieve_asset_call_number([
149             $acn_id,
150             {
151                 flesh => 1,
152                 flesh_fields => {
153                     acn => ['copies']
154                 }
155             }
156         ]);
157         my %location_count = (); my $winning_location; my $winning_total;
158         use Data::Dumper;
159         foreach my $copy (@{$call_number->copies()}) {
160             if (! defined $location_count{ $copy->location() }) {
161                 $location_count{ $copy->location() } = 1;
162             } else {
163                 $location_count{ $copy->location() } += 1;
164             }
165             if ($location_count{ $copy->location() } > $winning_total) {
166                 $winning_total = $location_count{ $copy->location() };
167                 $winning_location = $copy->location();
168             }
169         }
170
171         my $location = new_editor(xact=>1)->retrieve_asset_copy_location([
172             $winning_location, {}
173         ]);
174         return $location;
175     },
176
177     # returns the org unit setting value
178     get_org_setting => sub {
179         my($org_id, $setting) = @_;
180         return $U->ou_ancestor_setting_value($org_id, $setting);
181     },
182
183     get_user_setting => sub {
184         my ($user_id, $setting) = @_;
185         my $val = new_editor()->search_actor_user_setting(
186             {usr => $user_id, name => $setting})->[0];
187         return undef unless $val; 
188         return OpenSRF::Utils::JSON->JSON2perl($val->value);  
189     },
190
191     # This basically greps/maps out ths isbn string values, but also promotes the first isbn-13 to the
192     # front of the line (so that the EDI translator takes it as primary) if there is one.
193     get_li_isbns => sub {
194         my $attrs = shift;
195         my @isbns;
196         my $primary;
197         foreach (@$attrs) {
198             $_->attr_name eq 'isbn' or next;
199             my $val = $_->attr_value;
200             if (! $primary and length($val) == 13) {
201                 $primary = $val;
202             } else {
203                 push @isbns, $val;
204             }
205         }
206         $primary and unshift @isbns, $primary;
207         $logger->debug("get_li_isbns returning isbns: " . join(', ', @isbns));
208         return @isbns;
209     },
210
211     get_li_order_ident => sub {
212         my $attrs = shift;
213
214         # preferred identifier
215         my ($attr) =  grep { $U->is_true($_->order_ident) } @$attrs;
216         return $attr if $attr;
217
218         # note we're not using get_li_attr, since we need the 
219         # attr object and not just the attr value
220
221         # isbn-13
222         ($attr) = grep { 
223             $_->attr_name eq 'isbn' and 
224             $_->attr_type eq 'lineitem_marc_attr_definition' and
225             length($_->attr_value) == 13
226         } @$attrs;
227         return $attr if $attr;
228
229         for my $name (qw/isbn issn upc/) {
230             ($attr) = grep { 
231                 $_->attr_name eq $name and 
232                 $_->attr_type eq 'lineitem_marc_attr_definition'
233             } @$attrs;
234             return $attr if $attr;
235         }
236
237         # any 'identifier' attr
238         return ( grep { $_->attr_name eq 'identifier' } @$attrs)[0];
239     },
240
241     # helpers.get_li_attr('isbn_13', li.attributes)
242     # returns matching line item attribute, or undef
243     get_li_attr => \&get_li_attr,
244
245     # get_li_attr_jedi() returns a JSON-encoded string without the enclosing
246     # quotes.  The function also removes other characters from the string
247     # that the EDI translator doesn't like.
248     #
249     # This *always* return a string, so don't use this in conditional
250     # expressions in your templates unless you really mean to.
251     get_li_attr_jedi => sub {
252         # This helper has to mangle data in at least three interesting ways.
253         #
254         # 1) We'll be receiving data that may already have some \-escaped
255         # characters.
256         #
257         # 2) We need our output to be valid JSON.
258         #
259         # 3) We need our output to yield valid and unproblematic EDI when
260         # passed through edi4r by the edi_pusher.pl script.
261
262         my $value = get_li_attr(@_);
263
264         {
265             no warnings 'uninitialized';
266             $value .= "";   # force to string
267         };
268
269         # Here we can add any number of special case transformations to
270         # avoid problems with the EDI translator (or bad JSON).
271
272         # Typical vendors dealing with EDIFACT (or is the problem with
273         # our EDI translator itself?) would seem not to want
274         # any characters outside the ASCII range, so trash them.
275         $value =~ s/[^[:ascii:]]//g;
276
277         # Remove anything somehow already JSON-escaped as a Unicode
278         # character. (even though for our part, we haven't JSON-escaped
279         # anything yet).
280         $value =~ s/\\u[0-9a-f]{4}//g;
281
282         # What the heck, get rid of [ ] too (although I couldn't get them
283         # to cause any problems for me, problems have been reported. See
284         # LP #812593).
285         $value =~ s/[\[\]]//g;
286
287         $value = OpenSRF::Utils::JSON->perl2JSON($value);
288
289         # Existing action/trigger templates expect an unquoted string.
290         $value =~ s/^"//g;
291         $value =~ s/"$//g;
292
293         # The ? character, if in the final position of a string, breaks
294         # the translator. + or ' or : could be problematic, too. And we must
295         # avoid leaving a hanging \.
296         while ($value =~ /[\\\?\+':]$/) {
297             chop $value;
298         }
299
300         return $value;
301     },
302
303     get_queued_bib_attr => sub {
304         my $name = shift or return;     # the first arg is always the name
305         my ($attr) = @_;
306         # use Data::Dumper; $logger->warn("get_queued_bib_attr: " . Dumper($attr));
307         ($name and @$attr) or return;
308
309         my $query = {
310             select => {'vqbrad' => ['id']},
311             from => 'vqbrad',
312             where => {code => $name}
313         };
314
315         my $def_ids = new_editor()->json_query($query);
316         @$def_ids or return;
317
318         my $length;
319         $name =~ s/^(\D+)_(\d+)$/$1/ and $length = $2;
320         foreach (@$attr) {
321             $_->field eq @{$def_ids}[0]->{id} or next;
322             next if $length and $length != length($_->attr_value);
323             return $_->attr_value;
324         }
325         return;
326     },
327
328     get_queued_auth_attr => sub {
329         my $name = shift or return;     # the first arg is always the name
330         my ($attr) = @_;
331         # use Data::Dumper; $logger->warn("get_queued_auth_attr: " . Dumper($attr));
332         ($name and @$attr) or return;
333
334         my $query = {
335             select => {'vqarad' => ['id']},
336             from => 'vqarad',
337             where => {code => $name}
338         };
339
340         my $def_ids = new_editor()->json_query($query);
341         @$def_ids or return;
342
343         my $length;
344         $name =~ s/^(\D+)_(\d+)$/$1/ and $length = $2;
345         foreach (@$attr) {
346             $_->field eq @{$def_ids}[0]->{id} or next;
347             next if $length and $length != length($_->attr_value);
348             return $_->attr_value;
349         }
350         return;
351     },
352
353     csv_datum => sub {
354         my ($str) = @_;
355
356         if ($str =~ /\,/ || $str =~ /"/) {
357             $str =~ s/"/""/g;
358             $str = '"' . $str . '"';
359         }
360
361         return $str;
362     },
363
364
365     bre_open_hold_count => sub {
366         my $bre_id = shift;
367         return 0 unless $bre_id;
368         return $U->simplereq(
369             'open-ils.circ',
370             'open-ils.circ.bre.holds.count', $bre_id);
371     },
372
373     xml_doc => sub {
374         my ($str) = @_;
375         return $str ? (new XML::LibXML)->parse_string($str) : undef;
376     },
377
378     # returns an email addresses derived from sms_carrier and sms_notify
379     get_sms_gateway_email => sub {
380         my $sms_carrier = shift;
381         my $sms_notify = shift;
382
383         if (! defined $sms_notify || $sms_notify eq '' || ! defined $sms_carrier || $sms_carrier eq '') {
384             return '';
385         }
386
387         my $query = {
388             select => {'csc' => ['id','name','email_gateway']},
389             from => 'csc',
390             where => {id => $sms_carrier}
391         };
392         my $carriers = new_editor()->json_query($query);
393
394         # If this looks like a pretty-formatted number drop the pretty-formatting
395         # Otherwise assume it may be a literal alias instead of a real number
396         if ($sms_notify =~ m/^[- ()0-9]*$/) {
397             $sms_notify =~ s/[- ()]//g;
398         }
399
400         my @addresses = ();
401         foreach my $carrier ( @{ $carriers } ) {
402             my $address = $carrier->{email_gateway};
403             $address =~ s/\$number/$sms_notify/g;
404             push @addresses, $address;
405         }
406
407         return join(',',@addresses);
408     },
409
410     unapi_bre => sub {
411         my ($bre_id, $unapi_args) = @_;
412         $unapi_args ||= {};
413         $unapi_args->{flesh} ||= '{}',
414
415         my $query = { 
416             from => [
417                 'unapi.bre', $bre_id, 'marcxml','record', 
418                 $unapi_args->{flesh}, 
419                 $unapi_args->{site}, 
420                 $unapi_args->{depth}, 
421                 $unapi_args->{flesh_limit}, 
422             ]
423         };
424
425         my $unapi = new_editor()->json_query($query);
426         return undef unless @$unapi;
427         return $_TT_helpers->{xml_doc}->($unapi->[0]->{'unapi.bre'});
428     },
429
430     # input: list of bib bucket items; output: sorted list of unapi_bre objects
431     sort_bucket_unapi_bre => sub {
432         my ($list, $unapi_args, $sortby, $sortdir) = @_;
433         #$logger->info("sort_bucket_unapi_bre unapi_bre params: " . join(', ', map { "$_: $$unapi_args{$_}" } keys(%$unapi_args)));
434         my @sorted_list;
435         for my $i (@$list) {
436             my $xml = $_TT_helpers->{unapi_bre}->($i->target_biblio_record_entry, $unapi_args);
437             if ($xml) {
438                 my $bib = { xml => $xml, id => $i->target_biblio_record_entry };
439
440                 $$bib{title} = '';
441                 for my $part ($xml->findnodes('//*[@tag="245"]/*[@code="a" or @code="b"]')) {
442                     $$bib{title} = $$bib{title} . $part->textContent;
443                 }
444                 $$bib{titlesort} = lc(substr($$bib{title}, $xml->findnodes('//*[@tag="245"]')->get_node(1)->getAttribute('ind2')))
445                     if ($$bib{title});
446
447                 $$bib{authorsort} = $$bib{author} = $xml->findnodes('//*[@tag="100"]/*[@code="a"]')->to_literal_delimited(' ');
448                 $$bib{authorsort} = lc($$bib{authorsort});
449                 $$bib{item_type} = $xml->findnodes('//*[local-name()="attributes"]/*[local-name()="field"][@name="item_type"]')->get_node(1)->getAttribute('coded-value');
450                 my $p = $xml->findnodes('//*[@tag="260" or @tag="264"]/*[@code="b"]')->get_node(1);
451                 $$bib{publisher} = $p ? $p->textContent : '';
452                 $$bib{pubdatesort} = $$bib{pubdate} = $xml->findnodes('//*[local-name()="attributes"]/*[local-name()="field"][@name="date1"]')->get_node(1)->textContent;
453                 $$bib{pubdatesort} = lc($$bib{pubdatesort});
454                 $$bib{isbn} = $xml->findnodes('//*[@tag="020"]/*[@code="a"]')->to_literal_delimited(', ');
455                 $$bib{issn} = $xml->findnodes('//*[@tag="022"]/*[@code="a"]')->to_literal_delimited(', ');
456                 $$bib{upc} = $xml->findnodes('//*[@tag="024"]/*[@code="a"]')->to_literal_delimited(', ');
457
458                 $$bib{holdings} = [];
459
460                 for my $vol ($xml->findnodes('//*[local-name()="volume" and @deleted="false" and @opac_visible="true"]')) {
461                     my $vol_data = {};
462                     $$vol_data{prefix_sort} = $vol->findnodes('.//*[local-name()="call_number_prefix"]')->get_node(1)->getAttribute('label_sortkey');
463                     $$vol_data{prefix} = $vol->findnodes('.//*[local-name()="call_number_prefix"]')->get_node(1)->getAttribute('label');
464                     $$vol_data{callnumber} = $vol->getAttribute('label');
465                     $$vol_data{callnumber_sort} = $vol->getAttribute('label_sortkey');
466                     $$vol_data{suffix_sort} = $vol->findnodes('.//*[local-name()="call_number_suffix"]')->get_node(1)->getAttribute('label_sortkey');
467                     $$vol_data{suffix} = $vol->findnodes('.//*[local-name()="call_number_suffix"]')->get_node(1)->getAttribute('label');
468                     #$logger->info("sort_bucket_unapi_bre found volume: " . join(', ', map { "$_: $$vol_data{$_}" } keys(%$vol_data)));
469
470                     my @copies;
471                     for my $cp ($vol->findnodes('.//*[local-name()="copy" and @deleted="false"]')) {
472                         my $cp_data = {%$vol_data};
473                         my $l = $cp->findnodes('.//*[local-name()="location" and @opac_visible="true"]')->get_node(1);
474                         next unless ($l);
475                         $$cp_data{location} = $l->textContent;
476
477                         my $s = $cp->findnodes('.//*[local-name()="status" and @opac_visible="true"]')->get_node(1);
478                         next unless ($s);
479                         $$cp_data{status_label} = $s->textContent;
480                         $$cp_data{status_id} = $s->getAttribute('ident');
481
482                         my $c = $cp->findnodes('.//*[local-name()="circ_lib" and @opac_visible="true"]')->get_node(1);
483                         next unless ($c);
484                         $$cp_data{circ_lib} = $c->getAttribute('name');
485
486                         $$cp_data{barcode} = $cp->getAttribute('barcode');
487
488                         $$cp_data{parts} = '';
489                         for my $mp ($cp->findnodes('.//*[local-name()="monograph_part"]')) {
490                             $$cp_data{parts} .= ', ' if $$cp_data{parts};
491                             $$cp_data{parts} .= $mp->textContent;
492                         }
493                         push @copies, $cp_data;
494                         #$logger->info("sort_bucket_unapi_bre found copy: " . join(', ', map { "$_: $$cp_data{$_}" } keys(%$cp_data)));
495                     }
496                     if (@copies) {
497                         push @{$$bib{holdings}}, @copies;
498                     }
499                 }
500
501                 # sort 'em!
502                 $$bib{holdings} = [ sort {
503                     $$a{circ_lib}     cmp $$b{circ_lib} ||
504                     $$a{location}     cmp $$b{location} ||
505                     $$a{prefix_sort}  cmp $$b{prefix_sort} ||
506                     $$a{callnumber_sort}   cmp $$b{callnumber_sort} ||
507                     $$a{suffix_sort}  cmp $$b{suffix_sort} ||
508                     ($$a{status_id} == 0 ? -1 : 0) ||
509                     ($$a{status_id} == 7 ? -1 : 0) ||
510                     $$a{status_label} cmp $$b{status_label};
511                 } @{$$bib{holdings}} ];
512
513                 push @sorted_list, $bib;
514             }
515         }
516
517         if ($sortdir =~ /^d/) {
518             return [ sort { $$b{$sortby.'sort'} cmp $$a{$sortby.'sort'} } @sorted_list ];
519         }
520         return [ sort { $$a{$sortby.'sort'} cmp $$b{$sortby.'sort'} } @sorted_list ];
521     },
522
523     # escapes quotes in csv string values
524     escape_csv => sub {
525         my $string = shift;
526         $string =~ s/"/""/og;
527         return $string;
528     }
529 };
530
531
532 # processes templates.  Returns template output on success, undef on error
533 sub run_TT {
534     my $self = shift;
535     my $env = shift;
536     my $nostore = shift;
537     return undef unless $env->{template};
538
539     my $error;
540     my $output = '';
541     my $tt = Template->new;
542     # my $tt = Template->new(ENCODING => 'utf8');   # ??
543     $env->{helpers} = $_TT_helpers;
544
545     unless( $tt->process(\$env->{template}, $env, \$output) ) {
546         $output = undef;
547         ($error = $tt->error) =~ s/\n/ /og;
548         $logger->error("Error processing Trigger template: $error");
549     }
550
551     if ( $error or (!$nostore && $output) ) {
552         my $t_o = Fieldmapper::action_trigger::event_output->new;
553         $t_o->data( ($error) ? $error : $output );
554         $t_o->is_error( ($error) ? 't' : 'f' );
555         $logger->info("trigger: writing " . length($t_o->data) . " bytes to template output");
556
557         $env->{EventProcessor}->editor->xact_begin;
558         $t_o = $env->{EventProcessor}->editor->create_action_trigger_event_output( $t_o );
559
560         my $state = (ref $$env{event} eq 'ARRAY') ? $$env{event}->[0]->state : $env->{event}->state;
561         my $key = ($error) ? 'error_output' : 'template_output';
562         $env->{EventProcessor}->update_state( $state, { $key => $t_o->id } );
563     }
564     
565     return $output;
566 }
567
568 # processes message templates.  Returns template output on success, undef on error
569 sub run_message_TT {
570     my $self = shift;
571     my $env = shift;
572     return undef unless $env->{usr_message}{template};
573
574     my $error;
575     my $output = '';
576     my $tt = Template->new;
577     # my $tt = Template->new(ENCODING => 'utf8');   # ??
578     $env->{helpers} = $_TT_helpers;
579
580     unless( $tt->process(\$env->{usr_message}{template}, $env, \$output) ) {
581         $output = undef;
582         ($error = $tt->error) =~ s/\n/ /og;
583         $logger->error("Error processing Trigger message template: $error");
584     }
585     
586     return $output;
587 }
588
589
590 1;