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