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