Bug 24436: Add handling for reserve transfer triggers
[koha.git] / C4 / Installer.pm
1 package C4::Installer;
2
3 # Copyright (C) 2008 LibLime
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use Encode qw( encode is_utf8 );
23 use DBIx::RunSQL;
24 use YAML::Syck qw( LoadFile );
25 use C4::Context;
26 use DBI;
27 use Koha;
28
29 use vars qw(@ISA @EXPORT);
30 BEGIN {
31     require Exporter;
32     @ISA = qw( Exporter );
33     push @EXPORT, qw( foreign_key_exists index_exists column_exists TableExists);
34 };
35
36 =head1 NAME
37
38 C4::Installer
39
40 =head1 SYNOPSIS
41
42  use C4::Installer;
43  my $installer = C4::Installer->new();
44  my $all_languages = getAllLanguages();
45  my $error = $installer->load_db_schema();
46  my $list;
47  #fill $list with list of sql files
48  my ($fwk_language, $error_list) = $installer->load_sql_in_order($all_languages, @$list);
49  $installer->set_version_syspref();
50  $installer->set_marcflavour_syspref('MARC21');
51
52 =head1 DESCRIPTION
53
54 =cut
55
56 =head1 METHODS
57
58 =head2 new
59
60   my $installer = C4::Installer->new();
61
62 Creates a new installer.
63
64 =cut
65
66 sub new {
67     my $class = shift;
68
69     my $self = {};
70
71     # get basic information from context
72     $self->{'dbname'}   = C4::Context->config("database");
73     $self->{'dbms'}     = C4::Context->config("db_scheme") ? C4::Context->config("db_scheme") : "mysql";
74     $self->{'hostname'} = C4::Context->config("hostname");
75     $self->{'port'}     = C4::Context->config("port");
76     $self->{'user'}     = C4::Context->config("user");
77     $self->{'password'} = C4::Context->config("pass");
78     $self->{'tls'} = C4::Context->config("tls");
79     if( $self->{'tls'} && $self->{'tls'} eq 'yes' ) {
80         $self->{'ca'} = C4::Context->config('ca');
81         $self->{'cert'} = C4::Context->config('cert');
82         $self->{'key'} = C4::Context->config('key');
83         $self->{'tlsoptions'} = ";mysql_ssl=1;mysql_ssl_client_key=".$self->{key}.";mysql_ssl_client_cert=".$self->{cert}.";mysql_ssl_ca_file=".$self->{ca};
84         $self->{'tlscmdline'} =  " --ssl-cert ". $self->{cert} . " --ssl-key " . $self->{key} . " --ssl-ca ".$self->{ca}." "
85     }
86     $self->{'dbh'} = DBI->connect("DBI:$self->{dbms}:dbname=$self->{dbname};host=$self->{hostname}" .
87                                   ( $self->{port} ? ";port=$self->{port}" : "" ).
88                                   ( $self->{tlsoptions} ? $self->{tlsoptions} : ""),
89                                   $self->{'user'}, $self->{'password'});
90     $self->{'language'} = undef;
91     $self->{'marcflavour'} = undef;
92         $self->{'dbh'}->do('set NAMES "utf8"');
93     $self->{'dbh'}->{'mysql_enable_utf8'}=1;
94
95     bless $self, $class;
96     return $self;
97 }
98
99 =head2 marc_framework_sql_list
100
101   my ($defaulted_to_en, $list) = 
102      $installer->marc_framework_sql_list($lang, $marcflavour);
103
104 Returns in C<$list> a structure listing the filename, description, section,
105 and mandatory/optional status of MARC framework scripts available for C<$lang>
106 and C<$marcflavour>.
107
108 If the C<$defaulted_to_en> return value is true, no scripts are available
109 for language C<$lang> and the 'en' ones are returned.
110
111 =cut
112
113 sub marc_framework_sql_list {
114     my $self = shift;
115     my $lang = shift;
116     my $marcflavour = shift;
117
118     my $defaulted_to_en = 0;
119
120     undef $/;
121     my $dir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/$lang/marcflavour/".lc($marcflavour);
122     unless (opendir( MYDIR, $dir )) {
123         if ($lang eq 'en') {
124             warn "cannot open MARC frameworks directory $dir";
125         } else {
126             # if no translated MARC framework is available,
127             # default to English
128             $dir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/en/marcflavour/".lc($marcflavour);
129             opendir(MYDIR, $dir) or warn "cannot open English MARC frameworks directory $dir";
130             $defaulted_to_en = 1;
131         }
132     }
133     my @listdir = sort grep { !/^\.|marcflavour/ && -d "$dir/$_" } readdir(MYDIR);
134     closedir MYDIR;
135
136     my @fwklist;
137     my $request = $self->{'dbh'}->prepare("SELECT value FROM systempreferences WHERE variable='FrameworksLoaded'");
138     $request->execute;
139     my ($frameworksloaded) = $request->fetchrow;
140     $frameworksloaded = '' unless defined $frameworksloaded; # avoid warning
141     my %frameworksloaded;
142     foreach ( split( /\|/, $frameworksloaded ) ) {
143         $frameworksloaded{$_} = 1;
144     }
145
146     foreach my $requirelevel (@listdir) {
147         opendir( MYDIR, "$dir/$requirelevel" );
148         my @listname = grep { !/^\./ && -f "$dir/$requirelevel/$_" && $_ =~ m/\.(sql|yml)$/ } readdir(MYDIR);
149         closedir MYDIR;
150         my %cell;
151         my @frameworklist;
152         map {
153             my ( $name, $ext ) = split /\./, $_;
154             my @lines;
155             if ( $ext =~ /yml/ ) {
156                 my $yaml = LoadFile("$dir/$requirelevel/$name\.$ext");
157                 @lines = map { Encode::decode('UTF-8', $_) } @{ $yaml->{'description'} };
158             } else {
159                 open my $fh, "<:encoding(UTF-8)", "$dir/$requirelevel/$name.txt";
160                 my $line = <$fh>;
161                 $line = Encode::encode('UTF-8', $line) unless ( Encode::is_utf8($line) );
162                 @lines = split /\n/, $line;
163             }
164             my $mandatory = ($requirelevel =~ /(mandatory|requi|oblig|necess)/i);
165             push @frameworklist,
166               {
167                 'fwkname'        => $name,
168                 'fwkfile'        => "$dir/$requirelevel/$_",
169                 'fwkdescription' => \@lines,
170                 'checked'        => ( ( $frameworksloaded{$_} || $mandatory ) ? 1 : 0 ),
171                 'mandatory'      => $mandatory,
172               };
173         } @listname;
174         my @fwks =
175           sort { $a->{'fwkname'} cmp $b->{'fwkname'} } @frameworklist;
176
177         $cell{"frameworks"} = \@fwks;
178         $cell{"label"}      = ($requirelevel =~ /(mandatory|requi|oblig|necess)/i)?'mandatory':'optional';
179         $cell{"code"}       = lc($requirelevel);
180         push @fwklist, \%cell;
181     }
182
183     return ($defaulted_to_en, \@fwklist);
184 }
185
186 =head2 sample_data_sql_list
187
188   my ($defaulted_to_en, $list) = $installer->sample_data_sql_list($lang);
189
190 Returns in C<$list> a structure listing the filename, description, section,
191 and mandatory/optional status of sample data scripts available for C<$lang>.
192 If the C<$defaulted_to_en> return value is true, no scripts are available
193 for language C<$lang> and the 'en' ones are returned.
194
195 =cut
196
197 sub sample_data_sql_list {
198     my $self = shift;
199     my $lang = shift;
200
201     my $defaulted_to_en = 0;
202
203     undef $/;
204     my $dir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/$lang";
205     unless (opendir( MYDIR, $dir )) {
206         if ($lang eq 'en') {
207             warn "cannot open sample data directory $dir";
208         } else {
209             # if no sample data is available,
210             # default to English
211             $dir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/en";
212             opendir(MYDIR, $dir) or warn "cannot open English sample data directory $dir";
213             $defaulted_to_en = 1;
214         }
215     }
216     my @listdir = sort grep { !/^\.|marcflavour/ && -d "$dir/$_" } readdir(MYDIR);
217     closedir MYDIR;
218
219     my @levellist;
220     my $request = $self->{'dbh'}->prepare("SELECT value FROM systempreferences WHERE variable='FrameworksLoaded'");
221     $request->execute;
222     my ($frameworksloaded) = $request->fetchrow;
223     $frameworksloaded = '' unless defined $frameworksloaded; # avoid warning
224     my %frameworksloaded;
225     foreach ( split( /\|/, $frameworksloaded ) ) {
226         $frameworksloaded{$_} = 1;
227     }
228
229     foreach my $requirelevel (@listdir) {
230         opendir( MYDIR, "$dir/$requirelevel" );
231         my @listname = grep { !/^\./ && -f "$dir/$requirelevel/$_" && $_ =~ m/\.(sql|yml)$/ } readdir(MYDIR);
232         closedir MYDIR;
233         my %cell;
234         my @frameworklist;
235         map {
236             my ( $name, $ext ) = split /\./, $_;
237             my @lines;
238             if ( $ext =~ /yml/ ) {
239                 my $yaml = LoadFile("$dir/$requirelevel/$name\.$ext");
240                 @lines = map { Encode::decode('UTF-8', $_) } @{ $yaml->{'description'} };
241             } else {
242                 open my $fh, "<:encoding(UTF-8)", "$dir/$requirelevel/$name.txt";
243                 my $line = <$fh>;
244                 $line = Encode::encode('UTF-8', $line) unless ( Encode::is_utf8($line) );
245                 @lines = split /\n/, $line;
246             }
247             my $mandatory = ($requirelevel =~ /(mandatory|requi|oblig|necess)/i);
248             push @frameworklist,
249               {
250                 'fwkname'        => $name,
251                 'fwkfile'        => "$dir/$requirelevel/$_",
252                 'fwkdescription' => \@lines,
253                 'checked'        => ( ( $frameworksloaded{$_} || $mandatory ) ? 1 : 0 ),
254                 'mandatory'      => $mandatory,
255               };
256         } @listname;
257         my @fwks = sort { $a->{'fwkname'} cmp $b->{'fwkname'} } @frameworklist;
258
259         $cell{"frameworks"} = \@fwks;
260         $cell{"label"}      = ($requirelevel =~ /(mandatory|requi|oblig|necess)/i)?'mandatory':'optional';
261         $cell{"code"}       = lc($requirelevel);
262         push @levellist, \%cell;
263     }
264
265     return ($defaulted_to_en, \@levellist);
266 }
267
268 =head2 load_db_schema
269
270   my $error = $installer->load_db_schema();
271
272 Loads the SQL script that creates Koha's tables and indexes.  The
273 return value is a string containing error messages reported by the
274 load.
275
276 =cut
277
278 sub load_db_schema {
279     my $self = shift;
280
281     my $datadir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}";
282     my $error = $self->load_sql("$datadir/kohastructure.sql");
283     return $error;
284
285 }
286
287 =head2 load_sql_in_order
288
289   my ($fwk_language, $list) = $installer->load_sql_in_order($all_languages, @sql_list);
290
291 Given a list of SQL scripts supplied in C<@sql_list>, loads each of them
292 into the database and sets the FrameworksLoaded system preference to names
293 of the scripts that were loaded.
294
295 The SQL files are loaded in alphabetical order by filename (not including
296 directory path).  This means that dependencies among the scripts are to
297 be resolved by carefully naming them, keeping in mind that the directory name
298 does *not* currently count.
299
300 B<FIXME:> this is a rather delicate way of dealing with dependencies between
301 the install scripts.
302
303 The return value C<$list> is an arrayref containing a hashref for each
304 "level" or directory containing SQL scripts; the hashref in turns contains
305 a list of hashrefs containing a list of each script load and any error
306 messages associated with the loading of each script.
307
308 B<FIXME:> The C<$fwk_language> code probably doesn't belong and needs to be
309 moved to a different method.
310
311 =cut
312
313 sub load_sql_in_order {
314     my $self = shift;
315     my $all_languages = shift;
316     my @sql_list = @_;
317
318     my $lang;
319     my %hashlevel;
320     my @fnames = sort {
321         my @aa = split /\/|\\/, ($a);
322         my @bb = split /\/|\\/, ($b);
323         $aa[-1] cmp $bb[-1]
324     } @sql_list;
325     my $request = $self->{'dbh'}->prepare( "SELECT value FROM systempreferences WHERE variable='FrameworksLoaded'" );
326     $request->execute;
327     my ($systempreference) = $request->fetchrow;
328     $systempreference = '' unless defined $systempreference; # avoid warning
329
330     my $global_mandatory_dir = C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/mandatory";
331
332     # Make sure some stuffs are loaded first
333     unshift(@fnames, C4::Context->config('intranetdir') . "/installer/data/$self->{dbms}/sysprefs.sql");
334     unshift(@fnames,
335         "$global_mandatory_dir/subtag_registry.sql",
336         "$global_mandatory_dir/auth_val_cat.sql",
337         "$global_mandatory_dir/message_transport_types.sql",
338         "$global_mandatory_dir/sample_notices_message_attributes.sql",
339         "$global_mandatory_dir/sample_notices_message_transports.sql",
340         "$global_mandatory_dir/keyboard_shortcuts.sql",
341     );
342
343     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/userflags.sql";
344     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/userpermissions.sql";
345     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/audio_alerts.sql";
346     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/account_offset_types.sql";
347     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/account_credit_types.sql";
348     push @fnames, C4::Context->config('intranetdir') . "/installer/data/mysql/account_debit_types.sql";
349     foreach my $file (@fnames) {
350         #      warn $file;
351         undef $/;
352         my $error = $self->load_sql($file);
353         my @file = split qr(\/|\\), $file;
354         $lang = $file[ scalar(@file) - 3 ] unless ($lang);
355         my $level = $file[ scalar(@file) - 2 ];
356         unless ($error) {
357             $systempreference .= "$file[scalar(@file)-1]|"
358               unless ( index( $systempreference, $file[ scalar(@file) - 1 ] ) >= 0 );
359         }
360
361         #Bulding here a hierarchy to display files by level.
362         push @{ $hashlevel{$level} },
363           { "fwkname" => $file[ scalar(@file) - 1 ], "error" => $error };
364     }
365
366     #systempreference contains an ending |
367     chop $systempreference;
368     my @list;
369     map { push @list, { "level" => $_, "fwklist" => $hashlevel{$_} } } keys %hashlevel;
370     my $fwk_language;
371     for my $each_language (@$all_languages) {
372
373         #       warn "CODE".$each_language->{'language_code'};
374         #       warn "LANG:".$lang;
375         if ( $lang eq $each_language->{'language_code'} ) {
376             $fwk_language = $each_language->{language_locale_name};
377         }
378     }
379     my $updateflag =
380       $self->{'dbh'}->do(
381         "UPDATE systempreferences set value=\"$systempreference\" where variable='FrameworksLoaded'"
382       );
383
384     unless ( $updateflag == 1 ) {
385         my $string =
386             "INSERT INTO systempreferences (value, variable, explanation, type) VALUES (\"$systempreference\",'FrameworksLoaded','Frameworks loaded through webinstaller','choice')";
387         my $rq = $self->{'dbh'}->prepare($string);
388         $rq->execute;
389     }
390     return ($fwk_language, \@list);
391 }
392
393 =head2 set_marcflavour_syspref
394
395   $installer->set_marcflavour_syspref($marcflavour);
396
397 Set the 'marcflavour' system preference.  The incoming
398 C<$marcflavour> references to a subdirectory of
399 installer/data/$dbms/$lang/marcflavour, and is
400 normalized to MARC21, UNIMARC or NORMARC.
401
402 FIXME: this method assumes that the MARC flavour will be either
403 MARC21, UNIMARC or NORMARC.
404
405 =cut
406
407 sub set_marcflavour_syspref {
408     my $self = shift;
409     my $marcflavour = shift;
410
411     # we can have some variants of marc flavour, by having different directories, like : unimarc_small and unimarc_full, for small and complete unimarc frameworks.
412     # marc_cleaned finds the marcflavour, without the variant.
413     my $marc_cleaned = 'MARC21';
414     $marc_cleaned = 'UNIMARC' if $marcflavour =~ /unimarc/i;
415     $marc_cleaned = 'NORMARC' if $marcflavour =~ /normarc/i;
416     my $request =
417         $self->{'dbh'}->prepare(
418           "INSERT IGNORE INTO `systempreferences` (variable,value,explanation,options,type) VALUES('marcflavour','$marc_cleaned','Define global MARC flavor (MARC21, UNIMARC or NORMARC) used for character encoding','MARC21|UNIMARC|NORMARC','Choice');"
419         );
420     $request->execute;
421 }
422
423 =head2 set_version_syspref
424
425   $installer->set_version_syspref();
426
427 Set or update the 'Version' system preference to the current
428 Koha software version.
429
430 =cut
431
432 sub set_version_syspref {
433     my $self = shift;
434
435     my $kohaversion = Koha::version();
436     # remove the 3 last . to have a Perl number
437     $kohaversion =~ s/(.*\..*)\.(.*)\.(.*)/$1$2$3/;
438     if (C4::Context->preference('Version')) {
439         warn "UPDATE Version";
440         my $finish=$self->{'dbh'}->prepare("UPDATE systempreferences SET value=? WHERE variable='Version'");
441         $finish->execute($kohaversion);
442     } else {
443         warn "INSERT Version";
444         my $finish=$self->{'dbh'}->prepare("INSERT into systempreferences (variable,value,explanation) values ('Version',?,'The Koha database version. WARNING: Do not change this value manually, it is maintained by the webinstaller')");
445         $finish->execute($kohaversion);
446     }
447     C4::Context->clear_syspref_cache();
448 }
449
450 =head2 load_sql
451
452   my $error = $installer->load_sql($filename);
453
454 Runs the specified input file using a sql loader DBIx::RunSQL, or a yaml loader
455 Returns any strings sent to STDERR
456
457 # FIXME This should be improved: sometimes the caller and load_sql warn the same
458 error.
459
460 =cut
461
462 sub load_sql {
463     my $self = shift;
464     my $filename = shift;
465     my $error;
466
467     my $dbh = $self->{ dbh };
468
469     my $dup_stderr;
470     do {
471         local *STDERR;
472         open STDERR, ">>", \$dup_stderr;
473
474         if ( $filename =~ /sql$/ ) {                                                        # SQL files
475             eval {
476                 DBIx::RunSQL->run_sql_file(
477                     dbh     => $dbh,
478                     sql     => $filename,
479                 );
480             };
481         }
482         else {                                                                       # YAML files
483             eval {
484                 my $yaml         = LoadFile( $filename );                            # Load YAML
485                 for my $table ( @{ $yaml->{'tables'} } ) {
486                     my $table_name   = ( keys %$table )[0];                          # table name
487                     my @rows         = @{ $table->{$table_name}->{rows} };           #
488                     my @columns      = ( sort keys %{$rows[0]} );                    # column names
489                     my $fields       = join ",", map{sprintf("`%s`", $_)} @columns;  # idem, joined
490                     my $query        = "INSERT INTO $table_name ( $fields ) VALUES ";
491                     my @multiline    = @{ $table->{$table_name}->{'multiline'} };    # to check multiline values;
492                     my $placeholders = '(' . join ( ",", map { "?" } @columns ) . ')'; # '(?,..,?)' string
493                     my @values;
494                     foreach my $row ( @rows ) {
495                         push @values, map {
496                                         my $col = $_;
497                                         ( @multiline and grep { $_ eq $col } @multiline )
498                                         ? join "\r\n", @{$row->{$col}}                # join multiline values
499                                         : $row->{$col};
500                                      } @columns;
501                     }
502                     # Doing only 1 INSERT query for the whole table
503                     $query .= join ', ', ( $placeholders ) x scalar @rows;
504                     $dbh->do( $query, undef, @values );
505                 }
506                 for my $statement ( @{ $yaml->{'sql_statements'} } ) {               # extra SQL statements
507                     $dbh->do($statement);
508                 }
509             };
510         }
511         if ($@){
512             warn "Something went wrong loading file $filename ($@)";
513         }
514     };
515     #   errors thrown while loading installer data should be logged
516     if( $dup_stderr ) {
517         warn "C4::Installer::load_sql returned the following errors while attempting to load $filename:\n";
518         $error = $dup_stderr;
519     }
520
521     return $error;
522 }
523
524 =head2 get_file_path_from_name
525
526   my $filename = $installer->get_file_path_from_name('script_name');
527
528 searches through the set of known SQL scripts and finds the fully
529 qualified path name for the script that mathches the input.
530
531 returns undef if no match was found.
532
533
534 =cut
535
536 sub get_file_path_from_name {
537     my $self = shift;
538     my $partialname = shift;
539
540     my $lang = 'en'; # FIXME: how do I know what language I want?
541
542     my ($defaulted_to_en, $list) = $self->sample_data_sql_list($lang);
543     # warn( Data::Dumper->Dump( [ $list ], [ 'list' ] ) );
544
545     my @found;
546     foreach my $frameworklist ( @$list ) {
547         push @found, grep { $_->{'fwkfile'} =~ /$partialname$/ } @{$frameworklist->{'frameworks'}};
548     }
549
550     # warn( Data::Dumper->Dump( [ \@found ], [ 'found' ] ) );
551     if ( 0 == scalar @found ) {
552         return;
553     } elsif ( 1 < scalar @found ) {
554         warn "multiple results found for $partialname";
555         return;
556     } else {
557         return $found[0]->{'fwkfile'};
558     }
559
560 }
561
562 sub foreign_key_exists {
563     my ( $table_name, $constraint_name ) = @_;
564     my $dbh = C4::Context->dbh;
565     my (undef, $infos) = $dbh->selectrow_array(qq|SHOW CREATE TABLE $table_name|);
566     return $infos =~ m|CONSTRAINT `$constraint_name` FOREIGN KEY|;
567 }
568
569 sub index_exists {
570     my ( $table_name, $key_name ) = @_;
571     my $dbh = C4::Context->dbh;
572     my ($exists) = $dbh->selectrow_array(
573         qq|
574         SHOW INDEX FROM $table_name
575         WHERE key_name = ?
576         |, undef, $key_name
577     );
578     return $exists;
579 }
580
581 sub column_exists {
582     my ( $table_name, $column_name ) = @_;
583     return unless TableExists($table_name);
584     my $dbh = C4::Context->dbh;
585     my ($exists) = $dbh->selectrow_array(
586         qq|
587         SHOW COLUMNS FROM $table_name
588         WHERE Field = ?
589         |, undef, $column_name
590     );
591     return $exists;
592 }
593
594 sub TableExists { # Could be renamed table_exists for consistency
595     my $table = shift;
596     eval {
597                 my $dbh = C4::Context->dbh;
598                 local $dbh->{PrintError} = 0;
599                 local $dbh->{RaiseError} = 1;
600                 $dbh->do(qq{SELECT * FROM $table WHERE 1 = 0 });
601             };
602     return 1 unless $@;
603     return 0;
604 }
605
606
607 =head1 AUTHOR
608
609 C4::Installer is a refactoring of logic originally from installer/installer.pl, which was
610 originally written by Henri-Damien Laurant.
611
612 Koha Development Team <http://koha-community.org/>
613
614 Galen Charlton <galen.charlton@liblime.com>
615
616 =cut
617
618 1;