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