Bug 35291: Validate filepaths in linking files
[koha.git] / Koha / Calendar.pm
1 package Koha::Calendar;
2
3 use Modern::Perl;
4
5 use Carp qw( croak );
6 use DateTime;
7 use DateTime::Duration;
8 use C4::Context;
9 use Koha::Caches;
10 use Koha::Exceptions;
11 use Koha::Exceptions::Calendar;
12
13 # This limit avoids an infinite loop when searching for an open day in an
14 # always closed library
15 # The value is arbitrary, but it should be large enough to be able to
16 # consider there is no open days if we haven't found any with that many
17 # iterations, and small enough to allow the loop to end quickly
18 # See next_open_days and prev_open_days
19 use constant OPEN_DAYS_SEARCH_MAX_ITERATIONS => 5000;
20
21 sub new {
22     my ( $classname, %options ) = @_;
23     my $self = {};
24     bless $self, $classname;
25     for my $o_name ( keys %options ) {
26         my $o = lc $o_name;
27         $self->{$o} = $options{$o_name};
28     }
29     if ( !defined $self->{branchcode} ) {
30         croak 'No branchcode argument passed to Koha::Calendar->new';
31     }
32     $self->_init();
33     return $self;
34 }
35
36 sub _init {
37     my $self       = shift;
38     my $branch     = $self->{branchcode};
39     my $dbh        = C4::Context->dbh();
40     my $weekly_closed_days_sth = $dbh->prepare(
41 'SELECT weekday FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NOT NULL'
42     );
43     $weekly_closed_days_sth->execute( $branch );
44     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];
45     while ( my $tuple = $weekly_closed_days_sth->fetchrow_hashref ) {
46         $self->{weekly_closed_days}->[ $tuple->{weekday} ] = 1;
47     }
48     my $day_month_closed_days_sth = $dbh->prepare(
49 'SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL'
50     );
51     $day_month_closed_days_sth->execute( $branch );
52     $self->{day_month_closed_days} = {};
53     while ( my $tuple = $day_month_closed_days_sth->fetchrow_hashref ) {
54         $self->{day_month_closed_days}->{ $tuple->{month} }->{ $tuple->{day} } =
55           1;
56     }
57
58     $self->{test}            = 0;
59     return;
60 }
61
62 sub _holidays {
63     my ($self) = @_;
64
65     my $key      = $self->{branchcode} . "_holidays";
66     my $cache    = Koha::Caches->get_instance();
67     my $holidays = $cache->get_from_cache($key);
68
69     # $holidays looks like:
70     # {
71     #    20131122 => 1, # single_holiday
72     #    20131123 => 0, # exception_holiday
73     #    ...
74     # }
75
76     # Populate the cache if necessary
77     unless ($holidays) {
78         my $dbh = C4::Context->dbh;
79         $holidays = {};
80
81         # Add holidays for each branch
82         my $holidays_sth = $dbh->prepare(
83 'SELECT day, month, year, MAX(isexception) FROM special_holidays WHERE branchcode = ? GROUP BY day, month, year'
84         );
85         $holidays_sth->execute($self->{branchcode});
86
87         while ( my ( $day, $month, $year, $exception ) =
88             $holidays_sth->fetchrow )
89         {
90             my $datestring =
91                 sprintf( "%04d", $year )
92               . sprintf( "%02d", $month )
93               . sprintf( "%02d", $day );
94
95             $holidays->{$datestring} = $exception ? 0 : 1;
96         }
97         $cache->set_in_cache( $key, $holidays, { expiry => 76800 } );
98     }
99     return $holidays // {};
100 }
101
102 sub addDuration {
103     my ( $self, $startdate, $add_duration, $unit ) = @_;
104
105
106     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDuration: days_mode")
107         unless exists $self->{days_mode};
108
109     # Default to days duration (legacy support I guess)
110     if ( ref $add_duration ne 'DateTime::Duration' ) {
111         $add_duration = DateTime::Duration->new( days => $add_duration );
112     }
113
114     $unit ||= 'days'; # default days ?
115     my $dt;
116     if ( $unit eq 'hours' ) {
117         # Fixed for legacy support. Should be set as a branch parameter
118         my $return_by_hour = 10;
119
120         $dt = $self->addHours($startdate, $add_duration, $return_by_hour);
121     } else {
122         # days
123         $dt = $self->addDays($startdate, $add_duration);
124     }
125     return $dt;
126 }
127
128 sub addHours {
129     my ( $self, $startdate, $hours_duration, $return_by_hour ) = @_;
130     my $base_date = $startdate->clone();
131
132     $base_date->add_duration($hours_duration);
133
134     # If we are using the calendar behave for now as if Datedue
135     # was the chosen option (current intended behaviour)
136
137     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addHours: days_mode")
138         unless exists $self->{days_mode};
139
140     if ( $self->{days_mode} ne 'Days' &&
141           $self->is_holiday($base_date) ) {
142
143         if ( $hours_duration->is_negative() ) {
144             $base_date = $self->prev_open_days($base_date, 1);
145         } else {
146             $base_date = $self->next_open_days($base_date, 1);
147         }
148
149         $base_date->set_hour($return_by_hour);
150
151     }
152
153     return $base_date;
154 }
155
156 sub addDays {
157     my ( $self, $startdate, $days_duration ) = @_;
158     my $base_date = $startdate->clone();
159
160     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDays: days_mode")
161         unless exists $self->{days_mode};
162
163     if ( $self->{days_mode} eq 'Calendar' ) {
164         # use the calendar to skip all days the library is closed
165         # when adding
166         my $days = abs $days_duration->in_units('days');
167
168         if ( $days_duration->is_negative() ) {
169             while ($days) {
170                 $base_date = $self->prev_open_days($base_date, 1);
171                 --$days;
172             }
173         } else {
174             while ($days) {
175                 $base_date = $self->next_open_days($base_date, 1);
176                 --$days;
177             }
178         }
179
180     } else { # Days, Datedue or Dayweek
181         # use straight days, then use calendar to push
182         # the date to the next open day as appropriate
183         # if Datedue or Dayweek
184         $base_date->add_duration($days_duration);
185
186         if ( $self->{days_mode} eq 'Datedue' ||
187             $self->{days_mode} eq 'Dayweek') {
188             # Datedue or Dayweek, then use the calendar to push
189             # the date to the next open day if holiday
190             if ( $self->is_holiday($base_date) ) {
191                 my $dow = $base_date->day_of_week;
192                 my $days = $days_duration->in_units('days');
193                 # Is it a period based on weeks
194                 my $push_amt = $days % 7 == 0 ?
195                     $self->get_push_amt($base_date) : 1;
196                 if ( $days_duration->is_negative() ) {
197                     $base_date = $self->prev_open_days($base_date, $push_amt);
198                 } else {
199                     $base_date = $self->next_open_days($base_date, $push_amt);
200                 }
201             }
202         }
203     }
204
205     return $base_date;
206 }
207
208 sub get_push_amt {
209     my ( $self, $base_date) = @_;
210
211     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_push_amt: days_mode")
212         unless exists $self->{days_mode};
213
214     my $dow = $base_date->day_of_week;
215     # Representation fix
216     # DateTime object dow (1-7) where Monday is 1
217     # Arrays are 0-based where 0 = Sunday, not 7.
218     if ( $dow == 7 ) {
219         $dow = 0;
220     }
221
222     return (
223         # We're using Dayweek useDaysMode option
224         $self->{days_mode} eq 'Dayweek' &&
225         # It's not a permanently closed day
226         !$self->{weekly_closed_days}->[$dow]
227     ) ? 7 : 1;
228 }
229
230 sub is_holiday {
231     my ( $self, $dt ) = @_;
232
233     my $localdt = $dt->clone();
234     my $day   = $localdt->day;
235     my $month = $localdt->month;
236     my $ymd   = $localdt->ymd('');
237
238     #Change timezone to "floating" before doing any calculations or comparisons
239     $localdt->set_time_zone("floating");
240     $localdt->truncate( to => 'day' );
241
242     return $self->_holidays->{$ymd} if defined($self->_holidays->{$ymd});
243
244     my $dow = $localdt->day_of_week;
245     # Representation fix
246     # DateTime object dow (1-7) where Monday is 1
247     # Arrays are 0-based where 0 = Sunday, not 7.
248     if ( $dow == 7 ) {
249         $dow = 0;
250     }
251
252     if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
253         return 1;
254     }
255
256     if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
257         return 1;
258     }
259
260     # damn have to go to work after all
261     return 0;
262 }
263
264 sub next_open_days {
265     my ( $self, $dt, $to_add ) = @_;
266
267     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->next_open_days: days_mode")
268         unless exists $self->{days_mode};
269
270     my $base_date = $dt->clone();
271
272     $base_date->add(days => $to_add);
273     my $i = 0;
274     while ( $self->is_holiday($base_date) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) {
275         my $add_next = $self->get_push_amt($base_date);
276         $base_date->add(days => $add_next);
277         ++$i;
278     }
279
280     if ( $self->is_holiday($base_date) ) {
281         Koha::Exceptions::Calendar::NoOpenDays->throw(
282             sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) );
283     }
284
285     return $base_date;
286 }
287
288 sub prev_open_days {
289     my ( $self, $dt, $to_sub ) = @_;
290
291     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_open_days: days_mode")
292         unless exists $self->{days_mode};
293
294     my $base_date = $dt->clone();
295
296     # It feels logical to be passed a positive number, though we're
297     # subtracting, so do the right thing
298     $to_sub = $to_sub > 0 ? 0 - $to_sub : $to_sub;
299
300     $base_date->add(days => $to_sub);
301
302     my $i = 0;
303     while ( $self->is_holiday($base_date) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) {
304         my $sub_next = $self->get_push_amt($base_date);
305         # Ensure we're subtracting when we need to be
306         $sub_next = $sub_next > 0 ? 0 - $sub_next : $sub_next;
307         $base_date->add(days => $sub_next);
308         ++$i;
309     }
310
311     if ( $self->is_holiday($base_date) ) {
312         Koha::Exceptions::Calendar::NoOpenDays->throw(
313             sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) );
314     }
315
316     return $base_date;
317 }
318
319 sub days_forward {
320     my $self     = shift;
321     my $start_dt = shift;
322     my $num_days = shift;
323
324     Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->days_forward: days_mode")
325         unless exists $self->{days_mode};
326
327     return $start_dt unless $num_days > 0;
328
329     my $base_dt = $start_dt->clone();
330
331     while ($num_days--) {
332         $base_dt = $self->next_open_days($base_dt, 1);
333     }
334
335     return $base_dt;
336 }
337
338 sub days_between {
339     my $self     = shift;
340     my $start_dt = shift;
341     my $end_dt   = shift;
342
343     # Change time zone for date math and swap if needed
344     $start_dt = $start_dt->clone->set_time_zone('floating');
345     $end_dt = $end_dt->clone->set_time_zone('floating');
346     if( $start_dt->compare($end_dt) > 0 ) {
347         ( $start_dt, $end_dt ) = ( $end_dt, $start_dt );
348     }
349
350     # start and end should not be closed days
351     my $delta_days = $start_dt->delta_days($end_dt)->delta_days;
352     while( $start_dt->compare($end_dt) < 1 ) {
353         $delta_days-- if $self->is_holiday($start_dt);
354         $start_dt->add( days => 1 );
355     }
356     return DateTime::Duration->new( days => $delta_days );
357 }
358
359 sub hours_between {
360     my ($self, $start_date, $end_date) = @_;
361     my $start_dt = $start_date->clone()->set_time_zone('floating');
362     my $end_dt = $end_date->clone()->set_time_zone('floating');
363
364     my $duration = $end_dt->delta_ms($start_dt);
365     $start_dt->truncate( to => 'day' );
366     $end_dt->truncate( to => 'day' );
367
368     # NB this is a kludge in that it assumes all days are 24 hours
369     # However for hourly loans the logic should be expanded to
370     # take into account open/close times then it would be a duration
371     # of library open hours
372     my $skipped_days = 0;
373     while( $start_dt->compare($end_dt) < 1 ) {
374         $skipped_days++ if $self->is_holiday($start_dt);
375         $start_dt->add( days => 1 );
376     }
377
378     if ($skipped_days) {
379         $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
380     }
381
382     return $duration;
383 }
384
385 sub set_daysmode {
386     my ( $self, $mode ) = @_;
387
388     # if not testing this is a no op
389     if ( $self->{test} ) {
390         $self->{days_mode} = $mode;
391     }
392
393     return;
394 }
395
396 sub clear_weekly_closed_days {
397     my $self = shift;
398     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];    # Sunday only
399     return;
400 }
401
402 1;
403 __END__
404
405 =head1 NAME
406
407 Koha::Calendar - Object containing a branches calendar
408
409 =head1 SYNOPSIS
410
411   use Koha::Calendar
412
413   my $c = Koha::Calendar->new( branchcode => 'MAIN' );
414   my $dt = dt_from_string();
415
416   # are we open
417   $open = $c->is_holiday($dt);
418   # when will item be due if loan period = $dur (a DateTime::Duration object)
419   $duedate = $c->addDuration($dt,$dur,'days');
420
421
422 =head1 DESCRIPTION
423
424   Implements those features of C4::Calendar needed for Staffs Rolling Loans
425
426 =head1 METHODS
427
428 =head2 new : Create a calendar object
429
430 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
431
432 The option branchcode is required
433
434
435 =head2 addDuration
436
437     my $dt = $calendar->addDuration($date, $dur, $unit)
438
439 C<$date> is a DateTime object representing the starting date of the interval.
440
441 C<$offset> is a DateTime::Duration to add to it
442
443 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
444
445 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
446 parameter will be removed when issuingrules properly cope with that
447
448
449 =head2 addHours
450
451     my $dt = $calendar->addHours($date, $dur, $return_by_hour )
452
453 C<$date> is a DateTime object representing the starting date of the interval.
454
455 C<$offset> is a DateTime::Duration to add to it
456
457 C<$return_by_hour> is an integer value representing the opening hour for the branch
458
459 =head2 get_push_amt
460
461     my $amt = $calendar->get_push_amt($date)
462
463 C<$date> is a DateTime object representing a closed return date
464
465 Using the days_mode syspref value and the nature of the closed return
466 date, return how many days we should jump forward to find another return date
467
468 =head2 addDays
469
470     my $dt = $calendar->addDays($date, $dur)
471
472 C<$date> is a DateTime object representing the starting date of the interval.
473
474 C<$offset> is a DateTime::Duration to add to it
475
476 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
477
478 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
479 parameter will be removed when issuingrules properly cope with that
480
481 =head2 is_holiday
482
483 $yesno = $calendar->is_holiday($dt);
484
485 passed a DateTime object returns 1 if it is a closed day
486 0 if not according to the calendar
487
488 =head2 days_between
489
490 $duration = $calendar->days_between($start_dt, $end_dt);
491
492 Passed two dates returns a DateTime::Duration object measuring the length between them
493 ignoring closed days. Always returns a positive number irrespective of the
494 relative order of the parameters.
495
496 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
497
498 =head2 hours_between
499
500 $duration = $calendar->hours_between($start_dt, $end_dt);
501
502 Passed two dates returns a DateTime::Duration object measuring the length between them
503 ignoring closed days. Always returns a positive number irrespective of the
504 relative order of the parameters.
505
506 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
507
508 =head2 next_open_days
509
510 $datetime = $calendar->next_open_days($duedate_dt, $to_add)
511
512 Passed a Datetime and number of days,  returns another Datetime representing
513 the next open day after adding the passed number of days. It is intended for
514 use to calculate the due date when useDaysMode syspref is set to either
515 'Datedue', 'Calendar' or 'Dayweek'.
516
517 =head2 prev_open_days
518
519 $datetime = $calendar->prev_open_days($duedate_dt, $to_sub)
520
521 Passed a Datetime and a number of days, returns another Datetime
522 representing the previous open day after subtracting the number of passed
523 days. It is intended for use to calculate the due date when useDaysMode
524 syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
525
526 =head2 days_forward
527
528 $datetime = $calendar->days_forward($start_dt, $to_add)
529
530 Passed a Datetime and number of days, returns another Datetime representing
531 the next open day after adding the passed number of days. It is intended for
532 use to calculate the due date when useDaysMode syspref is set to either
533 'Datedue', 'Calendar' or 'Dayweek'.
534
535 =head2 set_daysmode
536
537 For testing only allows the calling script to change days mode
538
539 =head2 clear_weekly_closed_days
540
541 In test mode changes the testing set of closed days to a new set with
542 no closed days. TODO passing an array of closed days to this would
543 allow testing of more configurations
544
545 =head2 add_holiday
546
547 Passed a datetime object this will add it to the calendar's list of
548 closed days. This is for testing so that we can alter the Calenfar object's
549 list of specified dates
550
551 =head1 DIAGNOSTICS
552
553 Will croak if not passed a branchcode in new
554
555 =head1 BUGS AND LIMITATIONS
556
557 This only contains a limited subset of the functionality in C4::Calendar
558 Only enough to support Staffs Rolling loans
559
560 =head1 AUTHOR
561
562 Colin Campbell colin.campbell@ptfs-europe.com
563
564 =head1 LICENSE AND COPYRIGHT
565
566 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
567
568 Koha is free software; you can redistribute it and/or modify it
569 under the terms of the GNU General Public License as published by
570 the Free Software Foundation; either version 3 of the License, or
571 (at your option) any later version.
572
573 Koha is distributed in the hope that it will be useful, but
574 WITHOUT ANY WARRANTY; without even the implied warranty of
575 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
576 GNU General Public License for more details.
577
578 You should have received a copy of the GNU General Public License
579 along with Koha; if not, see <http://www.gnu.org/licenses>.