LP#1031335 No-op the escape_email_header helper
[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 OpenSRF::Utils 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(cleanse_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     # given a copy, returns the title and author in a hash
110     get_copy_bib_basics => sub {
111         my $copy_id = shift;
112         my $copy = new_editor(xact=>1)->retrieve_asset_copy([
113             $copy_id,
114             {
115                 flesh => 2,
116                 flesh_fields => {
117                     acp => ['call_number'],
118                     acn => ['record']
119                 }
120             }
121         ]);
122         if($copy->call_number->id == -1) {
123             return {
124                 title  => $copy->dummy_title,
125                 author => $copy->dummy_author,
126             };
127         } else {
128             my $mvr = $U->record_to_mvr($copy->call_number->record);
129             return {
130                 title  => $mvr->title,
131                 author => $mvr->author
132             };
133         }
134     },
135
136     # given a call number, returns the copy location with the most copies
137     get_most_populous_location => sub {
138         my $acn_id = shift;
139
140         # FIXME - there's probably a more efficient way to do this with json_query/SQL
141         my $call_number = new_editor(xact=>1)->retrieve_asset_call_number([
142             $acn_id,
143             {
144                 flesh => 1,
145                 flesh_fields => {
146                     acn => ['copies']
147                 }
148             }
149         ]);
150         my %location_count = (); my $winning_location; my $winning_total;
151         use Data::Dumper;
152         foreach my $copy (@{$call_number->copies()}) {
153             if (! defined $location_count{ $copy->location() }) {
154                 $location_count{ $copy->location() } = 1;
155             } else {
156                 $location_count{ $copy->location() } += 1;
157             }
158             if ($location_count{ $copy->location() } > $winning_total) {
159                 $winning_total = $location_count{ $copy->location() };
160                 $winning_location = $copy->location();
161             }
162         }
163
164         my $location = new_editor(xact=>1)->retrieve_asset_copy_location([
165             $winning_location, {}
166         ]);
167         return $location;
168     },
169
170     # returns the org unit setting value
171     get_org_setting => sub {
172         my($org_id, $setting) = @_;
173         return $U->ou_ancestor_setting_value($org_id, $setting);
174     },
175
176     get_user_setting => sub {
177         my ($user_id, $setting) = @_;
178         my $val = new_editor()->search_actor_user_setting(
179             {usr => $user_id, name => $setting})->[0];
180         return undef unless $val; 
181         return OpenSRF::Utils::JSON->JSON2perl($val->value);  
182     },
183
184     # This basically greps/maps out ths isbn string values, but also promotes the first isbn-13 to the
185     # front of the line (so that the EDI translator takes it as primary) if there is one.
186     get_li_isbns => sub {
187         my $attrs = shift;
188         my @isbns;
189         my $primary;
190         foreach (@$attrs) {
191             $_->attr_name eq 'isbn' or next;
192             my $val = $_->attr_value;
193             if (! $primary and length($val) == 13) {
194                 $primary = $val;
195             } else {
196                 push @isbns, $val;
197             }
198         }
199         $primary and unshift @isbns, $primary;
200         $logger->debug("get_li_isbns returning isbns: " . join(', ', @isbns));
201         return @isbns;
202     },
203
204     get_li_order_ident => sub {
205         my $attrs = shift;
206
207         # preferred identifier
208         my ($attr) =  grep { $U->is_true($_->order_ident) } @$attrs;
209         return $attr if $attr;
210
211         # note we're not using get_li_attr, since we need the 
212         # attr object and not just the attr value
213
214         # isbn-13
215         ($attr) = grep { 
216             $_->attr_name eq 'isbn' and 
217             $_->attr_type eq 'lineitem_marc_attr_definition' and
218             length($_->attr_value) == 13
219         } @$attrs;
220         return $attr if $attr;
221
222         for my $name (qw/isbn issn upc/) {
223             ($attr) = grep { 
224                 $_->attr_name eq $name and 
225                 $_->attr_type eq 'lineitem_marc_attr_definition'
226             } @$attrs;
227             return $attr if $attr;
228         }
229
230         # any 'identifier' attr
231         return ( grep { $_->attr_name eq 'identifier' } @$attrs)[0];
232     },
233
234     # helpers.get_li_attr('isbn_13', li.attributes)
235     # returns matching line item attribute, or undef
236     get_li_attr => \&get_li_attr,
237
238     # get_li_attr_jedi() returns a JSON-encoded string without the enclosing
239     # quotes.  The function also removes other characters from the string
240     # that the EDI translator doesn't like.
241     #
242     # This *always* return a string, so don't use this in conditional
243     # expressions in your templates unless you really mean to.
244     get_li_attr_jedi => sub {
245         # This helper has to mangle data in at least three interesting ways.
246         #
247         # 1) We'll be receiving data that may already have some \-escaped
248         # characters.
249         #
250         # 2) We need our output to be valid JSON.
251         #
252         # 3) We need our output to yield valid and unproblematic EDI when
253         # passed through edi4r by the edi_pusher.pl script.
254
255         my $value = get_li_attr(@_);
256
257         {
258             no warnings 'uninitialized';
259             $value .= "";   # force to string
260         };
261
262         # Here we can add any number of special case transformations to
263         # avoid problems with the EDI translator (or bad JSON).
264
265         # Typical vendors dealing with EDIFACT (or is the problem with
266         # our EDI translator itself?) would seem not to want
267         # any characters outside the ASCII range, so trash them.
268         $value =~ s/[^[:ascii:]]//g;
269
270         # Remove anything somehow already JSON-escaped as a Unicode
271         # character. (even though for our part, we haven't JSON-escaped
272         # anything yet).
273         $value =~ s/\\u[0-9a-f]{4}//g;
274
275         # What the heck, get rid of [ ] too (although I couldn't get them
276         # to cause any problems for me, problems have been reported. See
277         # LP #812593).
278         $value =~ s/[\[\]]//g;
279
280         $value = OpenSRF::Utils::JSON->perl2JSON($value);
281
282         # Existing action/trigger templates expect an unquoted string.
283         $value =~ s/^"//g;
284         $value =~ s/"$//g;
285
286         # The ? character, if in the final position of a string, breaks
287         # the translator. + or ' or : could be problematic, too. And we must
288         # avoid leaving a hanging \.
289         while ($value =~ /[\\\?\+':]$/) {
290             chop $value;
291         }
292
293         return $value;
294     },
295
296     get_queued_bib_attr => sub {
297         my $name = shift or return;     # the first arg is always the name
298         my ($attr) = @_;
299         # use Data::Dumper; $logger->warn("get_queued_bib_attr: " . Dumper($attr));
300         ($name and @$attr) or return;
301
302         my $query = {
303             select => {'vqbrad' => ['id']},
304             from => 'vqbrad',
305             where => {code => $name}
306         };
307
308         my $def_ids = new_editor()->json_query($query);
309         @$def_ids or return;
310
311         my $length;
312         $name =~ s/^(\D+)_(\d+)$/$1/ and $length = $2;
313         foreach (@$attr) {
314             $_->field eq @{$def_ids}[0]->{id} or next;
315             next if $length and $length != length($_->attr_value);
316             return $_->attr_value;
317         }
318         return;
319     },
320
321     get_queued_auth_attr => sub {
322         my $name = shift or return;     # the first arg is always the name
323         my ($attr) = @_;
324         # use Data::Dumper; $logger->warn("get_queued_auth_attr: " . Dumper($attr));
325         ($name and @$attr) or return;
326
327         my $query = {
328             select => {'vqarad' => ['id']},
329             from => 'vqarad',
330             where => {code => $name}
331         };
332
333         my $def_ids = new_editor()->json_query($query);
334         @$def_ids or return;
335
336         my $length;
337         $name =~ s/^(\D+)_(\d+)$/$1/ and $length = $2;
338         foreach (@$attr) {
339             $_->field eq @{$def_ids}[0]->{id} or next;
340             next if $length and $length != length($_->attr_value);
341             return $_->attr_value;
342         }
343         return;
344     },
345
346     csv_datum => sub {
347         my ($str) = @_;
348
349         if ($str =~ /\,/ || $str =~ /"/) {
350             $str =~ s/"/""/g;
351             $str = '"' . $str . '"';
352         }
353
354         return $str;
355     },
356
357
358     bre_open_hold_count => sub {
359         my $bre_id = shift;
360         return 0 unless $bre_id;
361         return $U->simplereq(
362             'open-ils.circ',
363             'open-ils.circ.bre.holds.count', $bre_id);
364     },
365
366     xml_doc => sub {
367         my ($str) = @_;
368         return $str ? (new XML::LibXML)->parse_string($str) : undef;
369     },
370
371     # returns an email addresses derived from sms_carrier and sms_notify
372     get_sms_gateway_email => sub {
373         my $sms_carrier = shift;
374         my $sms_notify = shift;
375
376         if (! defined $sms_notify || $sms_notify eq '') {
377             return '';
378         }
379
380         my $query = {
381             select => {'csc' => ['id','name','email_gateway']},
382             from => 'csc',
383             where => {id => $sms_carrier}
384         };
385         my $carriers = new_editor()->json_query($query);
386
387         # If this looks like a pretty-formatted number drop the pretty-formatting
388         # Otherwise assume it may be a literal alias instead of a real number
389         if ($sms_notify =~ m/^[- ()0-9]*$/) {
390             $sms_notify =~ s/[- ()]//g;
391         }
392
393         my @addresses = ();
394         foreach my $carrier ( @{ $carriers } ) {
395             my $address = $carrier->{email_gateway};
396             $address =~ s/\$number/$sms_notify/g;
397             push @addresses, $address;
398         }
399
400         return join(',',@addresses);
401     },
402
403     unapi_bre => sub {
404         my ($bre_id, $unapi_args) = @_;
405         $unapi_args ||= {};
406         $unapi_args->{flesh} ||= '{}',
407
408         my $query = { 
409             from => [
410                 'unapi.bre', $bre_id, 'marcxml','record', 
411                 $unapi_args->{flesh}, 
412                 $unapi_args->{site}, 
413                 $unapi_args->{depth}, 
414                 $unapi_args->{flesh_depth}, 
415             ]
416         };
417
418         my $unapi = new_editor()->json_query($query);
419         return undef unless @$unapi;
420         return $_TT_helpers->{xml_doc}->($unapi->[0]->{'unapi.bre'});
421     },
422
423     # escapes quotes in csv string values
424     escape_csv => sub {
425         my $string = shift;
426         $string =~ s/"/""/og;
427         return $string;
428     }
429 };
430
431
432 # processes templates.  Returns template output on success, undef on error
433 sub run_TT {
434     my $self = shift;
435     my $env = shift;
436     my $nostore = shift;
437     return undef unless $env->{template};
438
439     my $error;
440     my $output = '';
441     my $tt = Template->new;
442     # my $tt = Template->new(ENCODING => 'utf8');   # ??
443     $env->{helpers} = $_TT_helpers;
444
445     unless( $tt->process(\$env->{template}, $env, \$output) ) {
446         $output = undef;
447         ($error = $tt->error) =~ s/\n/ /og;
448         $logger->error("Error processing Trigger template: $error");
449     }
450
451     if ( $error or (!$nostore && $output) ) {
452         my $t_o = Fieldmapper::action_trigger::event_output->new;
453         $t_o->data( ($error) ? $error : $output );
454         $t_o->is_error( ($error) ? 't' : 'f' );
455         $logger->info("trigger: writing " . length($t_o->data) . " bytes to template output");
456
457         $env->{EventProcessor}->editor->xact_begin;
458         $t_o = $env->{EventProcessor}->editor->create_action_trigger_event_output( $t_o );
459
460         my $state = (ref $$env{event} eq 'ARRAY') ? $$env{event}->[0]->state : $env->{event}->state;
461         my $key = ($error) ? 'error_output' : 'template_output';
462         $env->{EventProcessor}->update_state( $state, { $key => $t_o->id } );
463     }
464     
465     return $output;
466 }
467
468 # processes message templates.  Returns template output on success, undef on error
469 sub run_message_TT {
470     my $self = shift;
471     my $env = shift;
472     return undef unless $env->{usr_message}{template};
473
474     my $error;
475     my $output = '';
476     my $tt = Template->new;
477     # my $tt = Template->new(ENCODING => 'utf8');   # ??
478     $env->{helpers} = $_TT_helpers;
479
480     unless( $tt->process(\$env->{usr_message}{template}, $env, \$output) ) {
481         $output = undef;
482         ($error = $tt->error) =~ s/\n/ /og;
483         $logger->error("Error processing Trigger message template: $error");
484     }
485     
486     return $output;
487 }
488
489
490 1;