Bug 21596: Handle empty string for other attributes when storing a patron
[koha.git] / Koha / Patron.pm
1 package Koha::Patron;
2
3 # Copyright ByWater Solutions 2014
4 # Copyright PTFS Europe 2016
5 #
6 # This file is part of Koha.
7 #
8 # Koha is free software; you can redistribute it and/or modify it under the
9 # terms of the GNU General Public License as published by the Free Software
10 # Foundation; either version 3 of the License, or (at your option) any later
11 # version.
12 #
13 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
14 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
15 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License along
18 # with Koha; if not, write to the Free Software Foundation, Inc.,
19 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
20
21 use Modern::Perl;
22
23 use Carp;
24 use List::MoreUtils qw( uniq );
25 use JSON qw( to_json );
26 use Text::Unaccent qw( unac_string );
27
28 use C4::Accounts;
29 use C4::Context;
30 use C4::Log;
31 use Koha::AuthUtils;
32 use Koha::Checkouts;
33 use Koha::Database;
34 use Koha::DateUtils;
35 use Koha::Exceptions::Password;
36 use Koha::Holds;
37 use Koha::Old::Checkouts;
38 use Koha::Patron::Categories;
39 use Koha::Patron::HouseboundProfile;
40 use Koha::Patron::HouseboundRole;
41 use Koha::Patron::Images;
42 use Koha::Patrons;
43 use Koha::Virtualshelves;
44 use Koha::Club::Enrollments;
45 use Koha::Account;
46 use Koha::Subscription::Routinglists;
47
48 use base qw(Koha::Object);
49
50 our $RESULTSET_PATRON_ID_MAPPING = {
51     Accountline          => 'borrowernumber',
52     Aqbasketuser         => 'borrowernumber',
53     Aqbudget             => 'budget_owner_id',
54     Aqbudgetborrower     => 'borrowernumber',
55     ArticleRequest       => 'borrowernumber',
56     BorrowerAttribute    => 'borrowernumber',
57     BorrowerDebarment    => 'borrowernumber',
58     BorrowerFile         => 'borrowernumber',
59     BorrowerModification => 'borrowernumber',
60     ClubEnrollment       => 'borrowernumber',
61     Issue                => 'borrowernumber',
62     ItemsLastBorrower    => 'borrowernumber',
63     Linktracker          => 'borrowernumber',
64     Message              => 'borrowernumber',
65     MessageQueue         => 'borrowernumber',
66     OldIssue             => 'borrowernumber',
67     OldReserve           => 'borrowernumber',
68     Rating               => 'borrowernumber',
69     Reserve              => 'borrowernumber',
70     Review               => 'borrowernumber',
71     SearchHistory        => 'userid',
72     Statistic            => 'borrowernumber',
73     Suggestion           => 'suggestedby',
74     TagAll               => 'borrowernumber',
75     Virtualshelfcontent  => 'borrowernumber',
76     Virtualshelfshare    => 'borrowernumber',
77     Virtualshelve        => 'owner',
78 };
79
80 =head1 NAME
81
82 Koha::Patron - Koha Patron Object class
83
84 =head1 API
85
86 =head2 Class Methods
87
88 =cut
89
90 =head3 new
91
92 =cut
93
94 sub new {
95     my ( $class, $params ) = @_;
96
97     return $class->SUPER::new($params);
98 }
99
100 =head3 fixup_cardnumber
101
102 Autogenerate next cardnumber from highest value found in database
103
104 =cut
105
106 sub fixup_cardnumber {
107     my ( $self ) = @_;
108     my $max = Koha::Patrons->search({
109         cardnumber => {-regexp => '^-?[0-9]+$'}
110     }, {
111         select => \'CAST(cardnumber AS SIGNED)',
112         as => ['cast_cardnumber']
113     })->_resultset->get_column('cast_cardnumber')->max;
114     $self->cardnumber(($max || 0) +1);
115 }
116
117 =head3 trim_whitespace
118
119 trim whitespace from data which has some non-whitespace in it.
120 Could be moved to Koha::Object if need to be reused
121
122 =cut
123
124 sub trim_whitespaces {
125     my( $self ) = @_;
126
127     my $schema  = Koha::Database->new->schema;
128     my @columns = $schema->source($self->_type)->columns;
129
130     for my $column( @columns ) {
131         my $value = $self->$column;
132         if ( defined $value ) {
133             $value =~ s/^\s*|\s*$//g;
134             $self->$column($value);
135         }
136     }
137     return $self;
138 }
139
140 =head3 plain_text_password
141
142 $patron->plain_text_password( $password );
143
144 stores a copy of the unencrypted password in the object
145 for use in code before encrypting for db
146
147 =cut
148
149 sub plain_text_password {
150     my ( $self, $password ) = @_;
151     if ( $password ) {
152         $self->{_plain_text_password} = $password;
153         return $self;
154     }
155     return $self->{_plain_text_password}
156         if $self->{_plain_text_password};
157
158     return;
159 }
160
161 =head3 store
162
163 Patron specific store method to cleanup record
164 and do other necessary things before saving
165 to db
166
167 =cut
168
169 sub store {
170     my ($self) = @_;
171
172     $self->_result->result_source->schema->txn_do(
173         sub {
174             if (
175                 C4::Context->preference("autoMemberNum")
176                 and ( not defined $self->cardnumber
177                     or $self->cardnumber eq '' )
178               )
179             {
180                 # Warning: The caller is responsible for locking the members table in write
181                 # mode, to avoid database corruption.
182                 # We are in a transaction but the table is not locked
183                 $self->fixup_cardnumber;
184             }
185
186             unless( $self->category->in_storage ) {
187                 Koha::Exceptions::Object::FKConstraint->throw(
188                     broken_fk => 'categorycode',
189                     value     => $self->categorycode,
190                 );
191             }
192
193             $self->trim_whitespaces;
194
195             # We don't want invalid dates in the db (mysql has a bad habit of inserting 0000-00-00)
196             $self->dateofbirth(undef)  unless $self->dateofbirth;
197             $self->debarred(undef)     unless $self->debarred;
198             $self->date_renewed(undef) unless $self->date_renewed;
199             $self->lastseen(undef)     unless $self->lastseen;
200             $self->updated_on(undef)   unless $self->updated_on;
201
202             # Set default values if not set
203             $self->sms_provider_id(undef) unless $self->sms_provider_id;
204             $self->guarantorid(undef)     unless $self->guarantorid;
205
206             # If flags == 0 or flags == '' => no permission
207             $self->flags(undef) unless $self->flags;
208
209             # tinyint or int
210             $self->gonenoaddress(0)  unless $self->gonenoaddress;
211             $self->login_attempts(0) unless $self->login_attempts;
212             $self->privacy_guarantor_checkouts(0) unless $self->privacy_guarantor_checkouts;
213
214             unless ( $self->in_storage ) {    #AddMember
215
216                 # Generate a valid userid/login if needed
217                 $self->generate_userid
218                   if not $self->userid or not $self->has_valid_userid;
219
220                 # Add expiration date if it isn't already there
221                 unless ( $self->dateexpiry ) {
222                     $self->dateexpiry( $self->category->get_expiry_date );
223                 }
224
225                 # Add enrollment date if it isn't already there
226                 unless ( $self->dateenrolled ) {
227                     $self->dateenrolled(dt_from_string);
228                 }
229
230                 # Set the privacy depending on the patron's category
231                 my $default_privacy = $self->category->default_privacy || q{};
232                 $default_privacy =
233                     $default_privacy eq 'default' ? 1
234                   : $default_privacy eq 'never'   ? 2
235                   : $default_privacy eq 'forever' ? 0
236                   :                                                   undef;
237                 $self->privacy($default_privacy);
238
239                 unless ( defined $self->privacy_guarantor_checkouts ) {
240                     $self->privacy_guarantor_checkouts(0);
241                 }
242
243                 # Make a copy of the plain text password for later use
244                 $self->plain_text_password( $self->password );
245
246                 # Create a disabled account if no password provided
247                 $self->password( $self->password
248                     ? Koha::AuthUtils::hash_password( $self->password )
249                     : '!' );
250
251                 $self->borrowernumber(undef);
252
253                 $self = $self->SUPER::store;
254
255                 $self->add_enrolment_fee_if_needed;
256
257                 logaction( "MEMBERS", "CREATE", $self->borrowernumber, "" )
258                   if C4::Context->preference("BorrowersLog");
259             }
260             else {    #ModMember
261
262                 # Come from ModMember, but should not be possible (?)
263                 $self->dateenrolled(undef) unless $self->dateenrolled;
264                 $self->dateexpiry(undef)   unless $self->dateexpiry;
265
266
267                 my $self_from_storage = $self->get_from_storage;
268                 # FIXME We should not deal with that here, callers have to do this job
269                 # Moved from ModMember to prevent regressions
270                 unless ( $self->userid ) {
271                     my $stored_userid = $self_from_storage->userid;
272                     $self->userid($stored_userid);
273                 }
274
275                 # Password must be updated using $self->update_password
276                 $self->password($self_from_storage->password);
277
278                 if ( C4::Context->preference('FeeOnChangePatronCategory')
279                     and $self->category->categorycode ne
280                     $self_from_storage->category->categorycode )
281                 {
282                     $self->add_enrolment_fee_if_needed;
283                 }
284
285                 my $borrowers_log = C4::Context->preference("BorrowersLog");
286                 my $previous_cardnumber = $self_from_storage->cardnumber;
287                 if ($borrowers_log
288                     && ( !defined $previous_cardnumber
289                         || $previous_cardnumber ne $self->cardnumber )
290                     )
291                 {
292                     logaction(
293                         "MEMBERS",
294                         "MODIFY",
295                         $self->borrowernumber,
296                         to_json(
297                             {
298                                 cardnumber_replaced => {
299                                     previous_cardnumber => $previous_cardnumber,
300                                     new_cardnumber      => $self->cardnumber,
301                                 }
302                             },
303                             { utf8 => 1, pretty => 1 }
304                         )
305                     );
306                 }
307
308                 logaction( "MEMBERS", "MODIFY", $self->borrowernumber,
309                     "UPDATE (executed w/ arg: " . $self->borrowernumber . ")" )
310                   if $borrowers_log;
311
312                 $self = $self->SUPER::store;
313             }
314         }
315     );
316     return $self;
317 }
318
319 =head3 delete
320
321 $patron->delete
322
323 Delete patron's holds, lists and finally the patron.
324
325 Lists owned by the borrower are deleted, but entries from the borrower to
326 other lists are kept.
327
328 =cut
329
330 sub delete {
331     my ($self) = @_;
332
333     my $deleted;
334     $self->_result->result_source->schema->txn_do(
335         sub {
336             # Delete Patron's holds
337             $self->holds->delete;
338
339             # Delete all lists and all shares of this borrower
340             # Consistent with the approach Koha uses on deleting individual lists
341             # Note that entries in virtualshelfcontents added by this borrower to
342             # lists of others will be handled by a table constraint: the borrower
343             # is set to NULL in those entries.
344             # NOTE:
345             # We could handle the above deletes via a constraint too.
346             # But a new BZ report 11889 has been opened to discuss another approach.
347             # Instead of deleting we could also disown lists (based on a pref).
348             # In that way we could save shared and public lists.
349             # The current table constraints support that idea now.
350             # This pref should then govern the results of other routines/methods such as
351             # Koha::Virtualshelf->new->delete too.
352             # FIXME Could be $patron->get_lists
353             $_->delete for Koha::Virtualshelves->search( { owner => $self->borrowernumber } );
354
355             $deleted = $self->SUPER::delete;
356
357             logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
358         }
359     );
360     return $deleted;
361 }
362
363
364 =head3 category
365
366 my $patron_category = $patron->category
367
368 Return the patron category for this patron
369
370 =cut
371
372 sub category {
373     my ( $self ) = @_;
374     return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
375 }
376
377 =head3 guarantor
378
379 Returns a Koha::Patron object for this patron's guarantor
380
381 =cut
382
383 sub guarantor {
384     my ( $self ) = @_;
385
386     return unless $self->guarantorid();
387
388     return Koha::Patrons->find( $self->guarantorid() );
389 }
390
391 sub image {
392     my ( $self ) = @_;
393
394     return scalar Koha::Patron::Images->find( $self->borrowernumber );
395 }
396
397 sub library {
398     my ( $self ) = @_;
399     return Koha::Library->_new_from_dbic($self->_result->branchcode);
400 }
401
402 =head3 guarantees
403
404 Returns the guarantees (list of Koha::Patron) of this patron
405
406 =cut
407
408 sub guarantees {
409     my ( $self ) = @_;
410
411     return Koha::Patrons->search( { guarantorid => $self->borrowernumber }, { order_by => { -asc => ['surname','firstname'] } } );
412 }
413
414 =head3 housebound_profile
415
416 Returns the HouseboundProfile associated with this patron.
417
418 =cut
419
420 sub housebound_profile {
421     my ( $self ) = @_;
422     my $profile = $self->_result->housebound_profile;
423     return Koha::Patron::HouseboundProfile->_new_from_dbic($profile)
424         if ( $profile );
425     return;
426 }
427
428 =head3 housebound_role
429
430 Returns the HouseboundRole associated with this patron.
431
432 =cut
433
434 sub housebound_role {
435     my ( $self ) = @_;
436
437     my $role = $self->_result->housebound_role;
438     return Koha::Patron::HouseboundRole->_new_from_dbic($role) if ( $role );
439     return;
440 }
441
442 =head3 siblings
443
444 Returns the siblings of this patron.
445
446 =cut
447
448 sub siblings {
449     my ( $self ) = @_;
450
451     my $guarantor = $self->guarantor;
452
453     return unless $guarantor;
454
455     return Koha::Patrons->search(
456         {
457             guarantorid => {
458                 '!=' => undef,
459                 '=' => $guarantor->id,
460             },
461             borrowernumber => {
462                 '!=' => $self->borrowernumber,
463             }
464         }
465     );
466 }
467
468 =head3 merge_with
469
470     my $patron = Koha::Patrons->find($id);
471     $patron->merge_with( \@patron_ids );
472
473     This subroutine merges a list of patrons into the patron record. This is accomplished by finding
474     all related patron ids for the patrons to be merged in other tables and changing the ids to be that
475     of the keeper patron.
476
477 =cut
478
479 sub merge_with {
480     my ( $self, $patron_ids ) = @_;
481
482     my @patron_ids = @{ $patron_ids };
483
484     # Ensure the keeper isn't in the list of patrons to merge
485     @patron_ids = grep { $_ ne $self->id } @patron_ids;
486
487     my $schema = Koha::Database->new()->schema();
488
489     my $results;
490
491     $self->_result->result_source->schema->txn_do( sub {
492         foreach my $patron_id (@patron_ids) {
493             my $patron = Koha::Patrons->find( $patron_id );
494
495             next unless $patron;
496
497             # Unbless for safety, the patron will end up being deleted
498             $results->{merged}->{$patron_id}->{patron} = $patron->unblessed;
499
500             while (my ($r, $field) = each(%$RESULTSET_PATRON_ID_MAPPING)) {
501                 my $rs = $schema->resultset($r)->search({ $field => $patron_id });
502                 $results->{merged}->{ $patron_id }->{updated}->{$r} = $rs->count();
503                 $rs->update({ $field => $self->id });
504             }
505
506             $patron->move_to_deleted();
507             $patron->delete();
508         }
509     });
510
511     return $results;
512 }
513
514
515
516 =head3 wants_check_for_previous_checkout
517
518     $wants_check = $patron->wants_check_for_previous_checkout;
519
520 Return 1 if Koha needs to perform PrevIssue checking, else 0.
521
522 =cut
523
524 sub wants_check_for_previous_checkout {
525     my ( $self ) = @_;
526     my $syspref = C4::Context->preference("checkPrevCheckout");
527
528     # Simple cases
529     ## Hard syspref trumps all
530     return 1 if ($syspref eq 'hardyes');
531     return 0 if ($syspref eq 'hardno');
532     ## Now, patron pref trumps all
533     return 1 if ($self->checkprevcheckout eq 'yes');
534     return 0 if ($self->checkprevcheckout eq 'no');
535
536     # More complex: patron inherits -> determine category preference
537     my $checkPrevCheckoutByCat = $self->category->checkprevcheckout;
538     return 1 if ($checkPrevCheckoutByCat eq 'yes');
539     return 0 if ($checkPrevCheckoutByCat eq 'no');
540
541     # Finally: category preference is inherit, default to 0
542     if ($syspref eq 'softyes') {
543         return 1;
544     } else {
545         return 0;
546     }
547 }
548
549 =head3 do_check_for_previous_checkout
550
551     $do_check = $patron->do_check_for_previous_checkout($item);
552
553 Return 1 if the bib associated with $ITEM has previously been checked out to
554 $PATRON, 0 otherwise.
555
556 =cut
557
558 sub do_check_for_previous_checkout {
559     my ( $self, $item ) = @_;
560
561     # Find all items for bib and extract item numbers.
562     my @items = Koha::Items->search({biblionumber => $item->{biblionumber}});
563     my @item_nos;
564     foreach my $item (@items) {
565         push @item_nos, $item->itemnumber;
566     }
567
568     # Create (old)issues search criteria
569     my $criteria = {
570         borrowernumber => $self->borrowernumber,
571         itemnumber => \@item_nos,
572     };
573
574     # Check current issues table
575     my $issues = Koha::Checkouts->search($criteria);
576     return 1 if $issues->count; # 0 || N
577
578     # Check old issues table
579     my $old_issues = Koha::Old::Checkouts->search($criteria);
580     return $old_issues->count;  # 0 || N
581 }
582
583 =head3 is_debarred
584
585 my $debarment_expiration = $patron->is_debarred;
586
587 Returns the date a patron debarment will expire, or undef if the patron is not
588 debarred
589
590 =cut
591
592 sub is_debarred {
593     my ($self) = @_;
594
595     return unless $self->debarred;
596     return $self->debarred
597       if $self->debarred =~ '^9999'
598       or dt_from_string( $self->debarred ) > dt_from_string;
599     return;
600 }
601
602 =head3 is_expired
603
604 my $is_expired = $patron->is_expired;
605
606 Returns 1 if the patron is expired or 0;
607
608 =cut
609
610 sub is_expired {
611     my ($self) = @_;
612     return 0 unless $self->dateexpiry;
613     return 0 if $self->dateexpiry =~ '^9999';
614     return 1 if dt_from_string( $self->dateexpiry ) < dt_from_string->truncate( to => 'day' );
615     return 0;
616 }
617
618 =head3 is_going_to_expire
619
620 my $is_going_to_expire = $patron->is_going_to_expire;
621
622 Returns 1 if the patron is going to expired, depending on the NotifyBorrowerDeparture pref or 0
623
624 =cut
625
626 sub is_going_to_expire {
627     my ($self) = @_;
628
629     my $delay = C4::Context->preference('NotifyBorrowerDeparture') || 0;
630
631     return 0 unless $delay;
632     return 0 unless $self->dateexpiry;
633     return 0 if $self->dateexpiry =~ '^9999';
634     return 1 if dt_from_string( $self->dateexpiry )->subtract( days => $delay ) < dt_from_string->truncate( to => 'day' );
635     return 0;
636 }
637
638 =head3 update_password
639
640 my $updated = $patron->update_password( $userid, $password );
641
642 Update the userid and the password of a patron.
643 If the userid already exists, returns and let DBIx::Class warns
644 This will add an entry to action_logs if BorrowersLog is set.
645
646 =cut
647
648 sub update_password {
649     my ( $self, $userid, $password ) = @_;
650     eval { $self->userid($userid)->store; };
651     return if $@; # Make sure the userid is not already in used by another patron
652
653     return 0 if $password eq '****' or $password eq '';
654
655     my $digest = Koha::AuthUtils::hash_password($password);
656     $self->update(
657         {
658             password       => $digest,
659             login_attempts => 0,
660         }
661     );
662
663     logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
664     return $digest;
665 }
666
667 =head3 set_password
668
669     $patron->set_password( $plain_text_password );
670
671 Set the patron's password.
672
673 =head4 Exceptions
674
675 The passed string is validated against the current password enforcement policy.
676 Exceptions are thrown if the password is not good enough.
677
678 =over 4
679
680 =item Koha::Exceptions::Password::TooShort
681
682 =item Koha::Exceptions::Password::WhitespaceCharacters
683
684 =item Koha::Exceptions::Password::TooWeak
685
686 =back
687
688 =cut
689
690 sub set_password {
691     my ( $self, $password ) = @_;
692
693     my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password );
694
695     if ( !$is_valid ) {
696         if ( $error eq 'too_short' ) {
697             my $min_length = C4::Context->preference('minPasswordLength');
698             $min_length = 3 if not $min_length or $min_length < 3;
699
700             my $password_length = length($password);
701             Koha::Exceptions::Password::TooShort->throw(
702                 { length => $password_length, min_length => $min_length } );
703         }
704         elsif ( $error eq 'has_whitespaces' ) {
705             Koha::Exceptions::Password::WhitespaceCharacters->throw();
706         }
707         elsif ( $error eq 'too_weak' ) {
708             Koha::Exceptions::Password::TooWeak->throw();
709         }
710     }
711
712     my $digest = Koha::AuthUtils::hash_password($password);
713     $self->update(
714         {   password       => $digest,
715             login_attempts => 0,
716         }
717     );
718
719     logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
720         if C4::Context->preference("BorrowersLog");
721
722     return $self;
723 }
724
725
726 =head3 renew_account
727
728 my $new_expiry_date = $patron->renew_account
729
730 Extending the subscription to the expiry date.
731
732 =cut
733
734 sub renew_account {
735     my ($self) = @_;
736     my $date;
737     if ( C4::Context->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
738         $date = ( dt_from_string gt dt_from_string( $self->dateexpiry ) ) ? dt_from_string : dt_from_string( $self->dateexpiry );
739     } else {
740         $date =
741             C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
742             ? dt_from_string( $self->dateexpiry )
743             : dt_from_string;
744     }
745     my $expiry_date = $self->category->get_expiry_date($date);
746
747     $self->dateexpiry($expiry_date);
748     $self->date_renewed( dt_from_string() );
749     $self->store();
750
751     $self->add_enrolment_fee_if_needed;
752
753     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
754     return dt_from_string( $expiry_date )->truncate( to => 'day' );
755 }
756
757 =head3 has_overdues
758
759 my $has_overdues = $patron->has_overdues;
760
761 Returns the number of patron's overdues
762
763 =cut
764
765 sub has_overdues {
766     my ($self) = @_;
767     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
768     return $self->_result->issues->search({ date_due => { '<' => $dtf->format_datetime( dt_from_string() ) } })->count;
769 }
770
771 =head3 track_login
772
773     $patron->track_login;
774     $patron->track_login({ force => 1 });
775
776     Tracks a (successful) login attempt.
777     The preference TrackLastPatronActivity must be enabled. Or you
778     should pass the force parameter.
779
780 =cut
781
782 sub track_login {
783     my ( $self, $params ) = @_;
784     return if
785         !$params->{force} &&
786         !C4::Context->preference('TrackLastPatronActivity');
787     $self->lastseen( dt_from_string() )->store;
788 }
789
790 =head3 move_to_deleted
791
792 my $is_moved = $patron->move_to_deleted;
793
794 Move a patron to the deletedborrowers table.
795 This can be done before deleting a patron, to make sure the data are not completely deleted.
796
797 =cut
798
799 sub move_to_deleted {
800     my ($self) = @_;
801     my $patron_infos = $self->unblessed;
802     delete $patron_infos->{updated_on}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
803     return Koha::Database->new->schema->resultset('Deletedborrower')->create($patron_infos);
804 }
805
806 =head3 article_requests
807
808 my @requests = $borrower->article_requests();
809 my $requests = $borrower->article_requests();
810
811 Returns either a list of ArticleRequests objects,
812 or an ArtitleRequests object, depending on the
813 calling context.
814
815 =cut
816
817 sub article_requests {
818     my ( $self ) = @_;
819
820     $self->{_article_requests} ||= Koha::ArticleRequests->search({ borrowernumber => $self->borrowernumber() });
821
822     return $self->{_article_requests};
823 }
824
825 =head3 article_requests_current
826
827 my @requests = $patron->article_requests_current
828
829 Returns the article requests associated with this patron that are incomplete
830
831 =cut
832
833 sub article_requests_current {
834     my ( $self ) = @_;
835
836     $self->{_article_requests_current} ||= Koha::ArticleRequests->search(
837         {
838             borrowernumber => $self->id(),
839             -or          => [
840                 { status => Koha::ArticleRequest::Status::Pending },
841                 { status => Koha::ArticleRequest::Status::Processing }
842             ]
843         }
844     );
845
846     return $self->{_article_requests_current};
847 }
848
849 =head3 article_requests_finished
850
851 my @requests = $biblio->article_requests_finished
852
853 Returns the article requests associated with this patron that are completed
854
855 =cut
856
857 sub article_requests_finished {
858     my ( $self, $borrower ) = @_;
859
860     $self->{_article_requests_finished} ||= Koha::ArticleRequests->search(
861         {
862             borrowernumber => $self->id(),
863             -or          => [
864                 { status => Koha::ArticleRequest::Status::Completed },
865                 { status => Koha::ArticleRequest::Status::Canceled }
866             ]
867         }
868     );
869
870     return $self->{_article_requests_finished};
871 }
872
873 =head3 add_enrolment_fee_if_needed
874
875 my $enrolment_fee = $patron->add_enrolment_fee_if_needed;
876
877 Add enrolment fee for a patron if needed.
878
879 =cut
880
881 sub add_enrolment_fee_if_needed {
882     my ($self) = @_;
883     my $enrolment_fee = $self->category->enrolmentfee;
884     if ( $enrolment_fee && $enrolment_fee > 0 ) {
885         # insert fee in patron debts
886         C4::Accounts::manualinvoice( $self->borrowernumber, '', '', 'A', $enrolment_fee );
887     }
888     return $enrolment_fee || 0;
889 }
890
891 =head3 checkouts
892
893 my $checkouts = $patron->checkouts
894
895 =cut
896
897 sub checkouts {
898     my ($self) = @_;
899     my $checkouts = $self->_result->issues;
900     return Koha::Checkouts->_new_from_dbic( $checkouts );
901 }
902
903 =head3 pending_checkouts
904
905 my $pending_checkouts = $patron->pending_checkouts
906
907 This method will return the same as $self->checkouts, but with a prefetch on
908 items, biblio and biblioitems.
909
910 It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
911
912 It should not be used directly, prefer to access fields you need instead of
913 retrieving all these fields in one go.
914
915
916 =cut
917
918 sub pending_checkouts {
919     my( $self ) = @_;
920     my $checkouts = $self->_result->issues->search(
921         {},
922         {
923             order_by => [
924                 { -desc => 'me.timestamp' },
925                 { -desc => 'issuedate' },
926                 { -desc => 'issue_id' }, # Sort by issue_id should be enough
927             ],
928             prefetch => { item => { biblio => 'biblioitems' } },
929         }
930     );
931     return Koha::Checkouts->_new_from_dbic( $checkouts );
932 }
933
934 =head3 old_checkouts
935
936 my $old_checkouts = $patron->old_checkouts
937
938 =cut
939
940 sub old_checkouts {
941     my ($self) = @_;
942     my $old_checkouts = $self->_result->old_issues;
943     return Koha::Old::Checkouts->_new_from_dbic( $old_checkouts );
944 }
945
946 =head3 get_overdues
947
948 my $overdue_items = $patron->get_overdues
949
950 Return the overdue items
951
952 =cut
953
954 sub get_overdues {
955     my ($self) = @_;
956     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
957     return $self->checkouts->search(
958         {
959             'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string) },
960         },
961         {
962             prefetch => { item => { biblio => 'biblioitems' } },
963         }
964     );
965 }
966
967 =head3 get_routing_lists
968
969 my @routinglists = $patron->get_routing_lists
970
971 Returns the routing lists a patron is subscribed to.
972
973 =cut
974
975 sub get_routing_lists {
976     my ($self) = @_;
977     my $routing_list_rs = $self->_result->subscriptionroutinglists;
978     return Koha::Subscription::Routinglists->_new_from_dbic($routing_list_rs);
979 }
980
981 =head3 get_age
982
983 my $age = $patron->get_age
984
985 Return the age of the patron
986
987 =cut
988
989 sub get_age {
990     my ($self)    = @_;
991     my $today_str = dt_from_string->strftime("%Y-%m-%d");
992     return unless $self->dateofbirth;
993     my $dob_str   = dt_from_string( $self->dateofbirth )->strftime("%Y-%m-%d");
994
995     my ( $dob_y,   $dob_m,   $dob_d )   = split /-/, $dob_str;
996     my ( $today_y, $today_m, $today_d ) = split /-/, $today_str;
997
998     my $age = $today_y - $dob_y;
999     if ( $dob_m . $dob_d > $today_m . $today_d ) {
1000         $age--;
1001     }
1002
1003     return $age;
1004 }
1005
1006 =head3 account
1007
1008 my $account = $patron->account
1009
1010 =cut
1011
1012 sub account {
1013     my ($self) = @_;
1014     return Koha::Account->new( { patron_id => $self->borrowernumber } );
1015 }
1016
1017 =head3 holds
1018
1019 my $holds = $patron->holds
1020
1021 Return all the holds placed by this patron
1022
1023 =cut
1024
1025 sub holds {
1026     my ($self) = @_;
1027     my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
1028     return Koha::Holds->_new_from_dbic($holds_rs);
1029 }
1030
1031 =head3 old_holds
1032
1033 my $old_holds = $patron->old_holds
1034
1035 Return all the historical holds for this patron
1036
1037 =cut
1038
1039 sub old_holds {
1040     my ($self) = @_;
1041     my $old_holds_rs = $self->_result->old_reserves->search( {}, { order_by => 'reservedate' } );
1042     return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
1043 }
1044
1045 =head3 notice_email_address
1046
1047   my $email = $patron->notice_email_address;
1048
1049 Return the email address of patron used for notices.
1050 Returns the empty string if no email address.
1051
1052 =cut
1053
1054 sub notice_email_address{
1055     my ( $self ) = @_;
1056
1057     my $which_address = C4::Context->preference("AutoEmailPrimaryAddress");
1058     # if syspref is set to 'first valid' (value == OFF), look up email address
1059     if ( $which_address eq 'OFF' ) {
1060         return $self->first_valid_email_address;
1061     }
1062
1063     return $self->$which_address || '';
1064 }
1065
1066 =head3 first_valid_email_address
1067
1068 my $first_valid_email_address = $patron->first_valid_email_address
1069
1070 Return the first valid email address for a patron.
1071 For now, the order  is defined as email, emailpro, B_email.
1072 Returns the empty string if the borrower has no email addresses.
1073
1074 =cut
1075
1076 sub first_valid_email_address {
1077     my ($self) = @_;
1078
1079     return $self->email() || $self->emailpro() || $self->B_email() || q{};
1080 }
1081
1082 =head3 get_club_enrollments
1083
1084 =cut
1085
1086 sub get_club_enrollments {
1087     my ( $self, $return_scalar ) = @_;
1088
1089     my $e = Koha::Club::Enrollments->search( { borrowernumber => $self->borrowernumber(), date_canceled => undef } );
1090
1091     return $e if $return_scalar;
1092
1093     return wantarray ? $e->as_list : $e;
1094 }
1095
1096 =head3 get_enrollable_clubs
1097
1098 =cut
1099
1100 sub get_enrollable_clubs {
1101     my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
1102
1103     my $params;
1104     $params->{is_enrollable_from_opac} = $is_enrollable_from_opac
1105       if $is_enrollable_from_opac;
1106     $params->{is_email_required} = 0 unless $self->first_valid_email_address();
1107
1108     $params->{borrower} = $self;
1109
1110     my $e = Koha::Clubs->get_enrollable($params);
1111
1112     return $e if $return_scalar;
1113
1114     return wantarray ? $e->as_list : $e;
1115 }
1116
1117 =head3 account_locked
1118
1119 my $is_locked = $patron->account_locked
1120
1121 Return true if the patron has reach the maximum number of login attempts (see pref FailedLoginAttempts).
1122 Otherwise return false.
1123 If the pref is not set (empty string, null or 0), the feature is considered as disabled.
1124
1125 =cut
1126
1127 sub account_locked {
1128     my ($self) = @_;
1129     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
1130     return ( $FailedLoginAttempts
1131           and $self->login_attempts
1132           and $self->login_attempts >= $FailedLoginAttempts )? 1 : 0;
1133 }
1134
1135 =head3 can_see_patron_infos
1136
1137 my $can_see = $patron->can_see_patron_infos( $patron );
1138
1139 Return true if the patron (usually the logged in user) can see the patron's infos for a given patron
1140
1141 =cut
1142
1143 sub can_see_patron_infos {
1144     my ( $self, $patron ) = @_;
1145     return $self->can_see_patrons_from( $patron->library->branchcode );
1146 }
1147
1148 =head3 can_see_patrons_from
1149
1150 my $can_see = $patron->can_see_patrons_from( $branchcode );
1151
1152 Return true if the patron (usually the logged in user) can see the patron's infos from a given library
1153
1154 =cut
1155
1156 sub can_see_patrons_from {
1157     my ( $self, $branchcode ) = @_;
1158     my $can = 0;
1159     if ( $self->branchcode eq $branchcode ) {
1160         $can = 1;
1161     } elsif ( $self->has_permission( { borrowers => 'view_borrower_infos_from_any_libraries' } ) ) {
1162         $can = 1;
1163     } elsif ( my $library_groups = $self->library->library_groups ) {
1164         while ( my $library_group = $library_groups->next ) {
1165             if ( $library_group->parent->has_child( $branchcode ) ) {
1166                 $can = 1;
1167                 last;
1168             }
1169         }
1170     }
1171     return $can;
1172 }
1173
1174 =head3 libraries_where_can_see_patrons
1175
1176 my $libraries = $patron-libraries_where_can_see_patrons;
1177
1178 Return the list of branchcodes(!) of libraries the patron is allowed to see other patron's infos.
1179 The branchcodes are arbitrarily returned sorted.
1180 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1181
1182 An empty array means no restriction, the patron can see patron's infos from any libraries.
1183
1184 =cut
1185
1186 sub libraries_where_can_see_patrons {
1187     my ( $self ) = @_;
1188     my $userenv = C4::Context->userenv;
1189
1190     return () unless $userenv; # For tests, but userenv should be defined in tests...
1191
1192     my @restricted_branchcodes;
1193     if (C4::Context::only_my_library) {
1194         push @restricted_branchcodes, $self->branchcode;
1195     }
1196     else {
1197         unless (
1198             $self->has_permission(
1199                 { borrowers => 'view_borrower_infos_from_any_libraries' }
1200             )
1201           )
1202         {
1203             my $library_groups = $self->library->library_groups({ ft_hide_patron_info => 1 });
1204             if ( $library_groups->count )
1205             {
1206                 while ( my $library_group = $library_groups->next ) {
1207                     my $parent = $library_group->parent;
1208                     if ( $parent->has_child( $self->branchcode ) ) {
1209                         push @restricted_branchcodes, $parent->children->get_column('branchcode');
1210                     }
1211                 }
1212             }
1213
1214             @restricted_branchcodes = ( $self->branchcode ) unless @restricted_branchcodes;
1215         }
1216     }
1217
1218     @restricted_branchcodes = grep { defined $_ } @restricted_branchcodes;
1219     @restricted_branchcodes = uniq(@restricted_branchcodes);
1220     @restricted_branchcodes = sort(@restricted_branchcodes);
1221     return @restricted_branchcodes;
1222 }
1223
1224 sub has_permission {
1225     my ( $self, $flagsrequired ) = @_;
1226     return unless $self->userid;
1227     # TODO code from haspermission needs to be moved here!
1228     return C4::Auth::haspermission( $self->userid, $flagsrequired );
1229 }
1230
1231 =head3 is_adult
1232
1233 my $is_adult = $patron->is_adult
1234
1235 Return true if the patron has a category with a type Adult (A) or Organization (I)
1236
1237 =cut
1238
1239 sub is_adult {
1240     my ( $self ) = @_;
1241     return $self->category->category_type =~ /^(A|I)$/ ? 1 : 0;
1242 }
1243
1244 =head3 is_child
1245
1246 my $is_child = $patron->is_child
1247
1248 Return true if the patron has a category with a type Child (C)
1249
1250 =cut
1251 sub is_child {
1252     my( $self ) = @_;
1253     return $self->category->category_type eq 'C' ? 1 : 0;
1254 }
1255
1256 =head3 has_valid_userid
1257
1258 my $patron = Koha::Patrons->find(42);
1259 $patron->userid( $new_userid );
1260 my $has_a_valid_userid = $patron->has_valid_userid
1261
1262 my $patron = Koha::Patron->new( $params );
1263 my $has_a_valid_userid = $patron->has_valid_userid
1264
1265 Return true if the current userid of this patron is valid/unique, otherwise false.
1266
1267 Note that this should be done in $self->store instead and raise an exception if needed.
1268
1269 =cut
1270
1271 sub has_valid_userid {
1272     my ($self) = @_;
1273
1274     return 0 unless $self->userid;
1275
1276     return 0 if ( $self->userid eq C4::Context->config('user') );    # DB user
1277
1278     my $already_exists = Koha::Patrons->search(
1279         {
1280             userid => $self->userid,
1281             (
1282                 $self->in_storage
1283                 ? ( borrowernumber => { '!=' => $self->borrowernumber } )
1284                 : ()
1285             ),
1286         }
1287     )->count;
1288     return $already_exists ? 0 : 1;
1289 }
1290
1291 =head3 generate_userid
1292
1293 my $patron = Koha::Patron->new( $params );
1294 $patron->generate_userid
1295
1296 Generate a userid using the $surname and the $firstname (if there is a value in $firstname).
1297
1298 Set a generated userid ($firstname.$surname if there is a $firstname, or $surname if there is no value in $firstname) plus offset (0 if the $userid is unique, or a higher numeric value if not unique).
1299
1300 =cut
1301
1302 sub generate_userid {
1303     my ($self) = @_;
1304     my $offset = 0;
1305     my $firstname = $self->firstname // q{};
1306     my $surname = $self->surname // q{};
1307     #The script will "do" the following code and increment the $offset until the generated userid is unique
1308     do {
1309       $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1310       $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1311       my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
1312       $userid = unac_string('utf-8',$userid);
1313       $userid .= $offset unless $offset == 0;
1314       $self->userid( $userid );
1315       $offset++;
1316      } while (! $self->has_valid_userid );
1317
1318      return $self;
1319
1320 }
1321
1322 =head2 Internal methods
1323
1324 =head3 _type
1325
1326 =cut
1327
1328 sub _type {
1329     return 'Borrower';
1330 }
1331
1332 =head1 AUTHOR
1333
1334 Kyle M Hall <kyle@bywatersolutions.com>
1335 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
1336
1337 =cut
1338
1339 1;