Bug 35291: Validate filepaths in linking files
[koha.git] / Koha / CirculationRules.pm
1 package Koha::CirculationRules;
2
3 # Copyright ByWater Solutions 2017
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 use Carp qw( croak );
22
23 use Koha::Exceptions;
24 use Koha::Exceptions::CirculationRule;
25 use Koha::CirculationRule;
26 use Koha::Caches;
27 use Koha::Cache::Memory::Lite;
28 use Koha::Number::Price;
29
30 use base qw(Koha::Objects);
31
32 use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';
33
34 =head1 NAME
35
36 Koha::CirculationRules - Koha CirculationRule Object set class
37
38 =head1 API
39
40 =head2 Class Methods
41
42 =cut
43
44 =head3 rule_kinds
45
46 This structure describes the possible rules that may be set, and what scopes they can be set at.
47
48 Any attempt to set a rule with a nonsensical scope (for instance, setting the C<patron_maxissueqty> for a branchcode and itemtype), is an error.
49
50 =cut
51
52 our $RULE_KINDS = {
53     lostreturn => {
54         scope => [ 'branchcode' ],
55     },
56     processingreturn => {
57         scope => [ 'branchcode' ],
58     },
59     patron_maxissueqty => {
60         scope => [ 'branchcode', 'categorycode' ],
61     },
62     patron_maxonsiteissueqty => {
63         scope => [ 'branchcode', 'categorycode' ],
64     },
65     max_holds => {
66         scope => [ 'branchcode', 'categorycode' ],
67     },
68
69     holdallowed => {
70         scope => [ 'branchcode', 'itemtype' ],
71         can_be_blank => 0,
72     },
73     hold_fulfillment_policy => {
74         scope => [ 'branchcode', 'itemtype' ],
75         can_be_blank => 0,
76     },
77     returnbranch => {
78         scope => [ 'branchcode', 'itemtype' ],
79         can_be_blank => 0,
80     },
81
82     article_requests => {
83         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
84     },
85     article_request_fee => {
86         scope => [ 'branchcode', 'categorycode' ],
87         is_monetary => 1,
88     },
89     open_article_requests_limit => {
90         scope => [ 'branchcode', 'categorycode' ],
91     },
92
93     auto_renew => {
94         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
95     },
96     cap_fine_to_replacement_price => {
97         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
98     },
99     chargeperiod => {
100         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
101     },
102     chargeperiod_charge_at => {
103         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
104     },
105     fine => {
106         scope       => [ 'branchcode', 'categorycode', 'itemtype' ],
107         is_monetary => 1,
108     },
109     finedays => {
110         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
111     },
112     firstremind => {
113         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
114     },
115     hardduedate => {
116         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117     },
118     hardduedatecompare => {
119         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
120     },
121     waiting_hold_cancellation => {
122         scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
123         can_be_blank => 0,
124     },
125     holds_per_day => {
126         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127     },
128     holds_per_record => {
129         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130     },
131     issuelength => {
132         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133     },
134     daysmode => {
135         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136     },
137     lengthunit => {
138         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139     },
140     maxissueqty => {
141         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142     },
143     maxonsiteissueqty => {
144         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145     },
146     maxsuspensiondays => {
147         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148     },
149     no_auto_renewal_after => {
150         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151     },
152     no_auto_renewal_after_hard_limit => {
153         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154     },
155     norenewalbefore => {
156         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157     },
158     noautorenewalbefore => {
159         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160     },
161     onshelfholds => {
162         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163     },
164     opacitemholds => {
165         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
166     },
167     overduefinescap => {
168         scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
169         is_monetary  => 1,
170         can_be_blank => 1,
171     },
172     renewalperiod => {
173         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
174     },
175     renewalsallowed => {
176         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
177     },
178     unseen_renewals_allowed => {
179         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
180     },
181     rentaldiscount => {
182         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
183         can_be_blank => 0,
184     },
185     reservesallowed => {
186         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
187     },
188     suspension_chargeperiod => {
189         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
190     },
191     note => { # This is not really a rule. Maybe we will want to separate this later.
192         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
193     },
194     decreaseloanholds => {
195         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
196     },
197     recalls_allowed => {
198         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
199     },
200     recalls_per_record => {
201         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
202     },
203     on_shelf_recalls => {
204         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
205     },
206     recall_due_date_interval => {
207         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
208     },
209     recall_overdue_fine => {
210         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
211         is_monetary => 1,
212     },
213     recall_shelf_time => {
214         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
215     },
216     holds_pickup_period => {
217         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
218     },
219     # Not included (deprecated?):
220     #   * accountsent
221     #   * reservecharge
222     #   * restrictedtype
223 };
224
225 sub rule_kinds {
226     return $RULE_KINDS;
227 }
228
229 =head3 get_effective_rule
230
231   my $effective_rule = Koha::CirculationRules->get_effective_rule(
232     {
233         rule_name    => $name,
234         categorycode => $categorycode,
235         itemtype     => $itemtype,
236         branchcode   => $branchcode
237     }
238   );
239
240 Return the effective rule object for the rule associated with the criteria passed.
241
242
243 =cut
244
245 sub get_effective_rule {
246     my ( $self, $params ) = @_;
247
248     $params->{categorycode} //= undef;
249     $params->{branchcode}   //= undef;
250     $params->{itemtype}     //= undef;
251
252     my $rule_name    = $params->{rule_name};
253     my $categorycode = $params->{categorycode};
254     my $itemtype     = $params->{itemtype};
255     my $branchcode   = $params->{branchcode};
256
257     Koha::Exceptions::MissingParameter->throw(
258         "Required parameter 'rule_name' missing")
259       unless $rule_name;
260
261     for my $v ( $branchcode, $categorycode, $itemtype ) {
262         $v = undef if $v and $v eq '*';
263     }
264
265     my $order_by = $params->{order_by}
266       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
267
268     my $search_params;
269     $search_params->{rule_name} = $rule_name;
270
271     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
272     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
273     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
274
275     my $rule = $self->search(
276         $search_params,
277         {
278             order_by => $order_by,
279             rows => 1,
280         }
281     )->single;
282
283     return $rule;
284 }
285
286 =head3 get_effective_rule_value
287
288   my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
289     {
290         rule_name    => $name,
291         categorycode => $categorycode,
292         itemtype     => $itemtype,
293         branchcode   => $branchcode
294     }
295   );
296
297 Return the effective value for the rule associated with the criteria passed.
298
299 This is a cached method so should be used in preference to get_effective_rule where possible
300 to aid performance.
301
302 =cut
303
304 sub get_effective_rule_value {
305     my ( $self, $params ) = @_;
306
307     my $rule_name    = $params->{rule_name};
308     my $categorycode = $params->{categorycode};
309     my $itemtype     = $params->{itemtype};
310     my $branchcode   = $params->{branchcode};
311
312     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
313     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
314       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
315
316     my $cached       = $memory_cache->get_from_cache($cache_key);
317     return $cached if $cached;
318
319     my $rule = $self->get_effective_rule($params);
320
321     my $value= $rule ? $rule->rule_value : undef;
322     $memory_cache->set_in_cache( $cache_key, $value );
323     return $value;
324 }
325
326 =head3 get_effective_rules
327
328 =cut
329
330 sub get_effective_rules {
331     my ( $self, $params ) = @_;
332
333     my $rules        = $params->{rules};
334     my $categorycode = $params->{categorycode};
335     my $itemtype     = $params->{itemtype};
336     my $branchcode   = $params->{branchcode};
337
338     my $r;
339     foreach my $rule (@$rules) {
340         my $effective_rule = $self->get_effective_rule_value(
341             {
342                 rule_name    => $rule,
343                 categorycode => $categorycode,
344                 itemtype     => $itemtype,
345                 branchcode   => $branchcode,
346             }
347         );
348
349         $r->{$rule} = $effective_rule if defined $effective_rule;
350     }
351
352     return $r;
353 }
354
355 =head3 set_rule
356
357 =cut
358
359 sub set_rule {
360     my ( $self, $params ) = @_;
361
362     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
363         Koha::Exceptions::MissingParameter->throw(
364             "Required parameter '$mandatory_parameter' missing")
365           unless exists $params->{$mandatory_parameter};
366     }
367
368     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
369     Koha::Exceptions::MissingParameter->throw(
370         "set_rule given unknown rule '$params->{rule_name}'!")
371         unless defined $kind_info;
372
373     # Enforce scope; a rule should be set for its defined scope, no more, no less.
374     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
375         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
376             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
377                 unless exists $params->{$scope_level};
378         } else {
379             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
380                 if exists $params->{$scope_level};
381         }
382     }
383
384     my $branchcode   = $params->{branchcode};
385     my $categorycode = $params->{categorycode};
386     my $itemtype     = $params->{itemtype};
387     my $rule_name    = $params->{rule_name};
388     my $rule_value   = $params->{rule_value};
389     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
390     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
391     my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
392     Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
393         if ( $is_monetary && defined($rule_value) && $rule_value ne '' && $rule_value !~ /^\d+(\.\d+)?$/ );
394
395     for my $v ( $branchcode, $categorycode, $itemtype ) {
396         $v = undef if $v and $v eq '*';
397     }
398     my $rule = $self->search(
399         {
400             rule_name    => $rule_name,
401             branchcode   => $branchcode,
402             categorycode => $categorycode,
403             itemtype     => $itemtype,
404         }
405     )->next();
406
407     if ($rule) {
408         if ( defined $rule_value ) {
409             $rule->rule_value($rule_value);
410             $rule->update();
411         }
412         else {
413             $rule->delete();
414         }
415     }
416     else {
417         if ( defined $rule_value ) {
418             $rule = Koha::CirculationRule->new(
419                 {
420                     branchcode   => $branchcode,
421                     categorycode => $categorycode,
422                     itemtype     => $itemtype,
423                     rule_name    => $rule_name,
424                     rule_value   => $rule_value,
425                 }
426             );
427             $rule->store();
428         }
429     }
430
431     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
432     for my $k ( $memory_cache->all_keys ) {
433         $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
434     }
435
436     return $rule;
437 }
438
439 =head3 set_rules
440
441 =cut
442
443 sub set_rules {
444     my ( $self, $params ) = @_;
445
446     my %set_params;
447     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
448     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
449     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
450     my $rules        = $params->{rules};
451
452     my $rule_objects = [];
453     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
454         my $rule_object = Koha::CirculationRules->set_rule(
455             {
456                 %set_params,
457                 rule_name    => $rule_name,
458                 rule_value   => $rule_value,
459             }
460         );
461         push( @$rule_objects, $rule_object );
462     }
463
464     return $rule_objects;
465 }
466
467 =head3 delete
468
469 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
470
471 =cut
472
473 sub delete {
474     my ( $self ) = @_;
475
476     while ( my $rule = $self->next ){
477         $rule->delete;
478     }
479 }
480
481 =head3 clone
482
483 Clone a set of circulation rules to another branch
484
485 =cut
486
487 sub clone {
488     my ( $self, $to_branch ) = @_;
489
490     while ( my $rule = $self->next ){
491         $rule->clone($to_branch);
492     }
493 }
494
495 =head2 get_return_branch_policy
496
497   my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
498
499 Returns the branch to use for returning the item based on the
500 item type, and a branch selected via CircControlReturnsBranch.
501
502 The return value is the branch to which to return the item. Possible values:
503   noreturn: do not return, let item remain where checked in (floating collections)
504   homebranch: return to item's home branch
505   holdingbranch: return to issuer branch
506
507 This searches branchitemrules in the following order:
508   * Same branchcode and itemtype
509   * Same branchcode, itemtype '*'
510   * branchcode '*', same itemtype
511   * branchcode '*' and itemtype '*'
512
513 =cut
514
515 sub get_return_branch_policy {
516     my ( $self, $item ) = @_;
517
518     my $pref = C4::Context->preference('CircControlReturnsBranch');
519
520     my $branchcode =
521         $pref eq 'ItemHomeLibrary'     ? $item->homebranch
522       : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
523       : $pref eq 'CheckInLibrary'      ? C4::Context->userenv
524           ? C4::Context->userenv->{branch}
525           : $item->homebranch
526       : $item->homebranch;
527
528     my $itemtype = $item->effective_itemtype;
529
530     my $rule = Koha::CirculationRules->get_effective_rule(
531         {
532             rule_name  => 'returnbranch',
533             itemtype   => $itemtype,
534             branchcode => $branchcode,
535         }
536     );
537
538     return $rule ? $rule->rule_value : 'homebranch';
539 }
540
541
542 =head3 get_opacitemholds_policy
543
544 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
545
546 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
547 and the "Item level holds" (opacitemholds).
548 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
549
550 =cut
551
552 sub get_opacitemholds_policy {
553     my ( $class, $params ) = @_;
554
555     my $item   = $params->{item};
556     my $patron = $params->{patron};
557
558     return unless $item or $patron;
559
560     my $rule = Koha::CirculationRules->get_effective_rule(
561         {
562             categorycode => $patron->categorycode,
563             itemtype     => $item->effective_itemtype,
564             branchcode   => $item->homebranch,
565             rule_name    => 'opacitemholds',
566         }
567     );
568
569     return $rule ? $rule->rule_value : undef;
570 }
571
572 =head3 get_onshelfholds_policy
573
574     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
575
576 =cut
577
578 sub get_onshelfholds_policy {
579     my ( $class, $params ) = @_;
580     my $item = $params->{item};
581     my $itemtype = $item->effective_itemtype;
582     my $patron = $params->{patron};
583     my $rule = Koha::CirculationRules->get_effective_rule(
584         {
585             categorycode => ( $patron ? $patron->categorycode : undef ),
586             itemtype     => $itemtype,
587             branchcode   => $item->holdingbranch,
588             rule_name    => 'onshelfholds',
589         }
590     );
591     return $rule ? $rule->rule_value : 0;
592 }
593
594 =head3 get_lostreturn_policy
595
596   my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
597
598 lostreturn return values are:
599
600 =over 2
601
602 =item '0' - Do not refund
603
604 =item 'refund' - Refund the lost item charge
605
606 =item 'restore' - Refund the lost item charge and restore the original overdue fine
607
608 =item 'charge' - Refund the lost item charge and charge a new overdue fine
609
610 =back
611
612 processing return return values are:
613
614 =over 2
615
616 =item '0' - Do not refund
617
618 =item 'refund' - Refund the lost item processing charge
619
620 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
621
622 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
623
624 =back
625
626
627 =cut
628
629 sub get_lostreturn_policy {
630     my ( $class, $params ) = @_;
631
632     my $item   = $params->{item};
633
634     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
635     my $behaviour_mapping = {
636         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
637         ItemHomeBranch    => $item->homebranch,
638         ItemHoldingBranch => $item->holdingbranch
639     };
640
641     my $branch = $behaviour_mapping->{ $behaviour };
642
643     my $rules = Koha::CirculationRules->get_effective_rules(
644         {
645             branchcode => $branch,
646             rules  => ['lostreturn','processingreturn']
647         }
648     );
649
650     $rules->{lostreturn} //= 'refund';
651     $rules->{processingreturn} //= 'refund';
652     return $rules;
653 }
654
655 =head3 article_requestable_rules
656
657     Return rules that allow article requests, optionally filtered by
658     patron categorycode.
659
660     Use with care; see guess_article_requestable_itemtypes.
661
662 =cut
663
664 sub article_requestable_rules {
665     my ( $class, $params ) = @_;
666     my $category = $params->{categorycode};
667
668     return if !C4::Context->preference('ArticleRequests');
669     return $class->search({
670         $category ? ( categorycode => [ $category, undef ] ) : (),
671         rule_name => 'article_requests',
672         rule_value => { '!=' => 'no' },
673     });
674 }
675
676 =head3 guess_article_requestable_itemtypes
677
678     Return item types in a hashref that are likely possible to be
679     'article requested'. Constructed by an intelligent guess in the
680     issuing rules (see article_requestable_rules).
681
682     Note: pref ArticleRequestsLinkControl overrides the algorithm.
683
684     Optional parameters: categorycode.
685
686     Note: the routine is used in opac-search to obtain a reasonable
687     estimate within performance borders (not looking at all items but
688     just using default itemtype). Also we are not looking at the
689     branchcode here, since home or holding branch of the item is
690     leading and branch may be unknown too (anonymous opac session).
691
692 =cut
693
694 sub guess_article_requestable_itemtypes {
695     my ( $class, $params ) = @_;
696     my $category = $params->{categorycode};
697     return {} if !C4::Context->preference('ArticleRequests');
698     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
699
700     my $cache = Koha::Caches->get_instance;
701     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
702     my $key = $category || '*';
703     return $last_article_requestable_guesses->{$key}
704         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
705
706     my $res = {};
707     my $rules = $class->article_requestable_rules({
708         $category ? ( categorycode => $category ) : (),
709     });
710     return $res if !$rules;
711     foreach my $rule ( $rules->as_list ) {
712         $res->{ $rule->itemtype // '*' } = 1;
713     }
714     $last_article_requestable_guesses->{$key} = $res;
715     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
716     return $res;
717 }
718
719 =head3 get_effective_daysmode
720
721 Return the value for daysmode defined in the circulation rules.
722 If not defined (or empty string), the value of the system preference useDaysMode is returned
723
724 =cut
725
726 sub get_effective_daysmode {
727     my ( $class, $params ) = @_;
728
729     my $categorycode     = $params->{categorycode};
730     my $itemtype         = $params->{itemtype};
731     my $branchcode       = $params->{branchcode};
732
733     my $daysmode_rule = $class->get_effective_rule(
734         {
735             categorycode => $categorycode,
736             itemtype     => $itemtype,
737             branchcode   => $branchcode,
738             rule_name    => 'daysmode',
739         }
740     );
741
742     return ( defined($daysmode_rule)
743           and $daysmode_rule->rule_value ne '' )
744       ? $daysmode_rule->rule_value
745       : C4::Context->preference('useDaysMode');
746
747 }
748
749
750 =head3 type
751
752 =cut
753
754 sub _type {
755     return 'CirculationRule';
756 }
757
758 =head3 object_class
759
760 =cut
761
762 sub object_class {
763     return 'Koha::CirculationRule';
764 }
765
766 1;