Bug 16284: Use new method to determine serials in check previous checkouts
[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 updated_on/);
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     my @item_nos;
560     my $biblio = Koha::Biblios->find( $item->{biblionumber} );
561     if ( $biblio->is_serial ) {
562         push @item_nos, $item->{itemnumber};
563     } else {
564         # Get all itemnumbers for given bibliographic record.
565         @item_nos = $biblio->items->get_column( '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 set_password
639
640     $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
641
642 Set the patron's password.
643
644 =head4 Exceptions
645
646 The passed string is validated against the current password enforcement policy.
647 Validation can be skipped by passing the I<skip_validation> parameter.
648
649 Exceptions are thrown if the password is not good enough.
650
651 =over 4
652
653 =item Koha::Exceptions::Password::TooShort
654
655 =item Koha::Exceptions::Password::WhitespaceCharacters
656
657 =item Koha::Exceptions::Password::TooWeak
658
659 =back
660
661 =cut
662
663 sub set_password {
664     my ( $self, $args ) = @_;
665
666     my $password = $args->{password};
667
668     unless ( $args->{skip_validation} ) {
669         my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password );
670
671         if ( !$is_valid ) {
672             if ( $error eq 'too_short' ) {
673                 my $min_length = C4::Context->preference('minPasswordLength');
674                 $min_length = 3 if not $min_length or $min_length < 3;
675
676                 my $password_length = length($password);
677                 Koha::Exceptions::Password::TooShort->throw(
678                     length => $password_length, min_length => $min_length );
679             }
680             elsif ( $error eq 'has_whitespaces' ) {
681                 Koha::Exceptions::Password::WhitespaceCharacters->throw();
682             }
683             elsif ( $error eq 'too_weak' ) {
684                 Koha::Exceptions::Password::TooWeak->throw();
685             }
686         }
687     }
688
689     my $digest = Koha::AuthUtils::hash_password($password);
690     $self->update(
691         {   password       => $digest,
692             login_attempts => 0,
693         }
694     );
695
696     logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
697         if C4::Context->preference("BorrowersLog");
698
699     return $self;
700 }
701
702
703 =head3 renew_account
704
705 my $new_expiry_date = $patron->renew_account
706
707 Extending the subscription to the expiry date.
708
709 =cut
710
711 sub renew_account {
712     my ($self) = @_;
713     my $date;
714     if ( C4::Context->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
715         $date = ( dt_from_string gt dt_from_string( $self->dateexpiry ) ) ? dt_from_string : dt_from_string( $self->dateexpiry );
716     } else {
717         $date =
718             C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
719             ? dt_from_string( $self->dateexpiry )
720             : dt_from_string;
721     }
722     my $expiry_date = $self->category->get_expiry_date($date);
723
724     $self->dateexpiry($expiry_date);
725     $self->date_renewed( dt_from_string() );
726     $self->store();
727
728     $self->add_enrolment_fee_if_needed;
729
730     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
731     return dt_from_string( $expiry_date )->truncate( to => 'day' );
732 }
733
734 =head3 has_overdues
735
736 my $has_overdues = $patron->has_overdues;
737
738 Returns the number of patron's overdues
739
740 =cut
741
742 sub has_overdues {
743     my ($self) = @_;
744     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
745     return $self->_result->issues->search({ date_due => { '<' => $dtf->format_datetime( dt_from_string() ) } })->count;
746 }
747
748 =head3 track_login
749
750     $patron->track_login;
751     $patron->track_login({ force => 1 });
752
753     Tracks a (successful) login attempt.
754     The preference TrackLastPatronActivity must be enabled. Or you
755     should pass the force parameter.
756
757 =cut
758
759 sub track_login {
760     my ( $self, $params ) = @_;
761     return if
762         !$params->{force} &&
763         !C4::Context->preference('TrackLastPatronActivity');
764     $self->lastseen( dt_from_string() )->store;
765 }
766
767 =head3 move_to_deleted
768
769 my $is_moved = $patron->move_to_deleted;
770
771 Move a patron to the deletedborrowers table.
772 This can be done before deleting a patron, to make sure the data are not completely deleted.
773
774 =cut
775
776 sub move_to_deleted {
777     my ($self) = @_;
778     my $patron_infos = $self->unblessed;
779     delete $patron_infos->{updated_on}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
780     return Koha::Database->new->schema->resultset('Deletedborrower')->create($patron_infos);
781 }
782
783 =head3 article_requests
784
785 my @requests = $borrower->article_requests();
786 my $requests = $borrower->article_requests();
787
788 Returns either a list of ArticleRequests objects,
789 or an ArtitleRequests object, depending on the
790 calling context.
791
792 =cut
793
794 sub article_requests {
795     my ( $self ) = @_;
796
797     $self->{_article_requests} ||= Koha::ArticleRequests->search({ borrowernumber => $self->borrowernumber() });
798
799     return $self->{_article_requests};
800 }
801
802 =head3 article_requests_current
803
804 my @requests = $patron->article_requests_current
805
806 Returns the article requests associated with this patron that are incomplete
807
808 =cut
809
810 sub article_requests_current {
811     my ( $self ) = @_;
812
813     $self->{_article_requests_current} ||= Koha::ArticleRequests->search(
814         {
815             borrowernumber => $self->id(),
816             -or          => [
817                 { status => Koha::ArticleRequest::Status::Pending },
818                 { status => Koha::ArticleRequest::Status::Processing }
819             ]
820         }
821     );
822
823     return $self->{_article_requests_current};
824 }
825
826 =head3 article_requests_finished
827
828 my @requests = $biblio->article_requests_finished
829
830 Returns the article requests associated with this patron that are completed
831
832 =cut
833
834 sub article_requests_finished {
835     my ( $self, $borrower ) = @_;
836
837     $self->{_article_requests_finished} ||= Koha::ArticleRequests->search(
838         {
839             borrowernumber => $self->id(),
840             -or          => [
841                 { status => Koha::ArticleRequest::Status::Completed },
842                 { status => Koha::ArticleRequest::Status::Canceled }
843             ]
844         }
845     );
846
847     return $self->{_article_requests_finished};
848 }
849
850 =head3 add_enrolment_fee_if_needed
851
852 my $enrolment_fee = $patron->add_enrolment_fee_if_needed;
853
854 Add enrolment fee for a patron if needed.
855
856 =cut
857
858 sub add_enrolment_fee_if_needed {
859     my ($self) = @_;
860     my $enrolment_fee = $self->category->enrolmentfee;
861     if ( $enrolment_fee && $enrolment_fee > 0 ) {
862         $self->account->add_debit(
863             {
864                 amount     => $enrolment_fee,
865                 user_id    => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
866                 interface  => C4::Context->interface,
867                 library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
868                 type       => 'account'
869             }
870         );
871     }
872     return $enrolment_fee || 0;
873 }
874
875 =head3 checkouts
876
877 my $checkouts = $patron->checkouts
878
879 =cut
880
881 sub checkouts {
882     my ($self) = @_;
883     my $checkouts = $self->_result->issues;
884     return Koha::Checkouts->_new_from_dbic( $checkouts );
885 }
886
887 =head3 pending_checkouts
888
889 my $pending_checkouts = $patron->pending_checkouts
890
891 This method will return the same as $self->checkouts, but with a prefetch on
892 items, biblio and biblioitems.
893
894 It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
895
896 It should not be used directly, prefer to access fields you need instead of
897 retrieving all these fields in one go.
898
899 =cut
900
901 sub pending_checkouts {
902     my( $self ) = @_;
903     my $checkouts = $self->_result->issues->search(
904         {},
905         {
906             order_by => [
907                 { -desc => 'me.timestamp' },
908                 { -desc => 'issuedate' },
909                 { -desc => 'issue_id' }, # Sort by issue_id should be enough
910             ],
911             prefetch => { item => { biblio => 'biblioitems' } },
912         }
913     );
914     return Koha::Checkouts->_new_from_dbic( $checkouts );
915 }
916
917 =head3 old_checkouts
918
919 my $old_checkouts = $patron->old_checkouts
920
921 =cut
922
923 sub old_checkouts {
924     my ($self) = @_;
925     my $old_checkouts = $self->_result->old_issues;
926     return Koha::Old::Checkouts->_new_from_dbic( $old_checkouts );
927 }
928
929 =head3 get_overdues
930
931 my $overdue_items = $patron->get_overdues
932
933 Return the overdue items
934
935 =cut
936
937 sub get_overdues {
938     my ($self) = @_;
939     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
940     return $self->checkouts->search(
941         {
942             'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string) },
943         },
944         {
945             prefetch => { item => { biblio => 'biblioitems' } },
946         }
947     );
948 }
949
950 =head3 get_routing_lists
951
952 my @routinglists = $patron->get_routing_lists
953
954 Returns the routing lists a patron is subscribed to.
955
956 =cut
957
958 sub get_routing_lists {
959     my ($self) = @_;
960     my $routing_list_rs = $self->_result->subscriptionroutinglists;
961     return Koha::Subscription::Routinglists->_new_from_dbic($routing_list_rs);
962 }
963
964 =head3 get_age
965
966 my $age = $patron->get_age
967
968 Return the age of the patron
969
970 =cut
971
972 sub get_age {
973     my ($self)    = @_;
974     my $today_str = dt_from_string->strftime("%Y-%m-%d");
975     return unless $self->dateofbirth;
976     my $dob_str   = dt_from_string( $self->dateofbirth )->strftime("%Y-%m-%d");
977
978     my ( $dob_y,   $dob_m,   $dob_d )   = split /-/, $dob_str;
979     my ( $today_y, $today_m, $today_d ) = split /-/, $today_str;
980
981     my $age = $today_y - $dob_y;
982     if ( $dob_m . $dob_d > $today_m . $today_d ) {
983         $age--;
984     }
985
986     return $age;
987 }
988
989 =head3 is_valid_age
990
991 my $is_valid = $patron->is_valid_age
992
993 Return 1 if patron's age is between allowed limits, returns 0 if it's not.
994
995 =cut
996
997 sub is_valid_age {
998     my ($self) = @_;
999     my $age = $self->get_age;
1000
1001     my $patroncategory = $self->category;
1002     my ($low,$high) = ($patroncategory->dateofbirthrequired, $patroncategory->upperagelimit);
1003
1004     return (defined($age) && (($high && ($age > $high)) or ($age < $low))) ? 0 : 1;
1005 }
1006
1007 =head3 account
1008
1009 my $account = $patron->account
1010
1011 =cut
1012
1013 sub account {
1014     my ($self) = @_;
1015     return Koha::Account->new( { patron_id => $self->borrowernumber } );
1016 }
1017
1018 =head3 holds
1019
1020 my $holds = $patron->holds
1021
1022 Return all the holds placed by this patron
1023
1024 =cut
1025
1026 sub holds {
1027     my ($self) = @_;
1028     my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
1029     return Koha::Holds->_new_from_dbic($holds_rs);
1030 }
1031
1032 =head3 old_holds
1033
1034 my $old_holds = $patron->old_holds
1035
1036 Return all the historical holds for this patron
1037
1038 =cut
1039
1040 sub old_holds {
1041     my ($self) = @_;
1042     my $old_holds_rs = $self->_result->old_reserves->search( {}, { order_by => 'reservedate' } );
1043     return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
1044 }
1045
1046 =head3 notice_email_address
1047
1048   my $email = $patron->notice_email_address;
1049
1050 Return the email address of patron used for notices.
1051 Returns the empty string if no email address.
1052
1053 =cut
1054
1055 sub notice_email_address{
1056     my ( $self ) = @_;
1057
1058     my $which_address = C4::Context->preference("AutoEmailPrimaryAddress");
1059     # if syspref is set to 'first valid' (value == OFF), look up email address
1060     if ( $which_address eq 'OFF' ) {
1061         return $self->first_valid_email_address;
1062     }
1063
1064     return $self->$which_address || '';
1065 }
1066
1067 =head3 first_valid_email_address
1068
1069 my $first_valid_email_address = $patron->first_valid_email_address
1070
1071 Return the first valid email address for a patron.
1072 For now, the order  is defined as email, emailpro, B_email.
1073 Returns the empty string if the borrower has no email addresses.
1074
1075 =cut
1076
1077 sub first_valid_email_address {
1078     my ($self) = @_;
1079
1080     return $self->email() || $self->emailpro() || $self->B_email() || q{};
1081 }
1082
1083 =head3 get_club_enrollments
1084
1085 =cut
1086
1087 sub get_club_enrollments {
1088     my ( $self, $return_scalar ) = @_;
1089
1090     my $e = Koha::Club::Enrollments->search( { borrowernumber => $self->borrowernumber(), date_canceled => undef } );
1091
1092     return $e if $return_scalar;
1093
1094     return wantarray ? $e->as_list : $e;
1095 }
1096
1097 =head3 get_enrollable_clubs
1098
1099 =cut
1100
1101 sub get_enrollable_clubs {
1102     my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
1103
1104     my $params;
1105     $params->{is_enrollable_from_opac} = $is_enrollable_from_opac
1106       if $is_enrollable_from_opac;
1107     $params->{is_email_required} = 0 unless $self->first_valid_email_address();
1108
1109     $params->{borrower} = $self;
1110
1111     my $e = Koha::Clubs->get_enrollable($params);
1112
1113     return $e if $return_scalar;
1114
1115     return wantarray ? $e->as_list : $e;
1116 }
1117
1118 =head3 account_locked
1119
1120 my $is_locked = $patron->account_locked
1121
1122 Return true if the patron has reached the maximum number of login attempts
1123 (see pref FailedLoginAttempts). If login_attempts is < 0, this is interpreted
1124 as an administrative lockout (independent of FailedLoginAttempts; see also
1125 Koha::Patron->lock).
1126 Otherwise return false.
1127 If the pref is not set (empty string, null or 0), the feature is considered as
1128 disabled.
1129
1130 =cut
1131
1132 sub account_locked {
1133     my ($self) = @_;
1134     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
1135     return 1 if $FailedLoginAttempts
1136           and $self->login_attempts
1137           and $self->login_attempts >= $FailedLoginAttempts;
1138     return 1 if ($self->login_attempts || 0) < 0; # administrative lockout
1139     return 0;
1140 }
1141
1142 =head3 can_see_patron_infos
1143
1144 my $can_see = $patron->can_see_patron_infos( $patron );
1145
1146 Return true if the patron (usually the logged in user) can see the patron's infos for a given patron
1147
1148 =cut
1149
1150 sub can_see_patron_infos {
1151     my ( $self, $patron ) = @_;
1152     return unless $patron;
1153     return $self->can_see_patrons_from( $patron->library->branchcode );
1154 }
1155
1156 =head3 can_see_patrons_from
1157
1158 my $can_see = $patron->can_see_patrons_from( $branchcode );
1159
1160 Return true if the patron (usually the logged in user) can see the patron's infos from a given library
1161
1162 =cut
1163
1164 sub can_see_patrons_from {
1165     my ( $self, $branchcode ) = @_;
1166     my $can = 0;
1167     if ( $self->branchcode eq $branchcode ) {
1168         $can = 1;
1169     } elsif ( $self->has_permission( { borrowers => 'view_borrower_infos_from_any_libraries' } ) ) {
1170         $can = 1;
1171     } elsif ( my $library_groups = $self->library->library_groups ) {
1172         while ( my $library_group = $library_groups->next ) {
1173             if ( $library_group->parent->has_child( $branchcode ) ) {
1174                 $can = 1;
1175                 last;
1176             }
1177         }
1178     }
1179     return $can;
1180 }
1181
1182 =head3 libraries_where_can_see_patrons
1183
1184 my $libraries = $patron-libraries_where_can_see_patrons;
1185
1186 Return the list of branchcodes(!) of libraries the patron is allowed to see other patron's infos.
1187 The branchcodes are arbitrarily returned sorted.
1188 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1189
1190 An empty array means no restriction, the patron can see patron's infos from any libraries.
1191
1192 =cut
1193
1194 sub libraries_where_can_see_patrons {
1195     my ( $self ) = @_;
1196     my $userenv = C4::Context->userenv;
1197
1198     return () unless $userenv; # For tests, but userenv should be defined in tests...
1199
1200     my @restricted_branchcodes;
1201     if (C4::Context::only_my_library) {
1202         push @restricted_branchcodes, $self->branchcode;
1203     }
1204     else {
1205         unless (
1206             $self->has_permission(
1207                 { borrowers => 'view_borrower_infos_from_any_libraries' }
1208             )
1209           )
1210         {
1211             my $library_groups = $self->library->library_groups({ ft_hide_patron_info => 1 });
1212             if ( $library_groups->count )
1213             {
1214                 while ( my $library_group = $library_groups->next ) {
1215                     my $parent = $library_group->parent;
1216                     if ( $parent->has_child( $self->branchcode ) ) {
1217                         push @restricted_branchcodes, $parent->children->get_column('branchcode');
1218                     }
1219                 }
1220             }
1221
1222             @restricted_branchcodes = ( $self->branchcode ) unless @restricted_branchcodes;
1223         }
1224     }
1225
1226     @restricted_branchcodes = grep { defined $_ } @restricted_branchcodes;
1227     @restricted_branchcodes = uniq(@restricted_branchcodes);
1228     @restricted_branchcodes = sort(@restricted_branchcodes);
1229     return @restricted_branchcodes;
1230 }
1231
1232 sub has_permission {
1233     my ( $self, $flagsrequired ) = @_;
1234     return unless $self->userid;
1235     # TODO code from haspermission needs to be moved here!
1236     return C4::Auth::haspermission( $self->userid, $flagsrequired );
1237 }
1238
1239 =head3 is_adult
1240
1241 my $is_adult = $patron->is_adult
1242
1243 Return true if the patron has a category with a type Adult (A) or Organization (I)
1244
1245 =cut
1246
1247 sub is_adult {
1248     my ( $self ) = @_;
1249     return $self->category->category_type =~ /^(A|I)$/ ? 1 : 0;
1250 }
1251
1252 =head3 is_child
1253
1254 my $is_child = $patron->is_child
1255
1256 Return true if the patron has a category with a type Child (C)
1257
1258 =cut
1259
1260 sub is_child {
1261     my( $self ) = @_;
1262     return $self->category->category_type eq 'C' ? 1 : 0;
1263 }
1264
1265 =head3 has_valid_userid
1266
1267 my $patron = Koha::Patrons->find(42);
1268 $patron->userid( $new_userid );
1269 my $has_a_valid_userid = $patron->has_valid_userid
1270
1271 my $patron = Koha::Patron->new( $params );
1272 my $has_a_valid_userid = $patron->has_valid_userid
1273
1274 Return true if the current userid of this patron is valid/unique, otherwise false.
1275
1276 Note that this should be done in $self->store instead and raise an exception if needed.
1277
1278 =cut
1279
1280 sub has_valid_userid {
1281     my ($self) = @_;
1282
1283     return 0 unless $self->userid;
1284
1285     return 0 if ( $self->userid eq C4::Context->config('user') );    # DB user
1286
1287     my $already_exists = Koha::Patrons->search(
1288         {
1289             userid => $self->userid,
1290             (
1291                 $self->in_storage
1292                 ? ( borrowernumber => { '!=' => $self->borrowernumber } )
1293                 : ()
1294             ),
1295         }
1296     )->count;
1297     return $already_exists ? 0 : 1;
1298 }
1299
1300 =head3 generate_userid
1301
1302 my $patron = Koha::Patron->new( $params );
1303 $patron->generate_userid
1304
1305 Generate a userid using the $surname and the $firstname (if there is a value in $firstname).
1306
1307 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).
1308
1309 =cut
1310
1311 sub generate_userid {
1312     my ($self) = @_;
1313     my $offset = 0;
1314     my $firstname = $self->firstname // q{};
1315     my $surname = $self->surname // q{};
1316     #The script will "do" the following code and increment the $offset until the generated userid is unique
1317     do {
1318       $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1319       $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1320       my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
1321       $userid = unac_string('utf-8',$userid);
1322       $userid .= $offset unless $offset == 0;
1323       $self->userid( $userid );
1324       $offset++;
1325      } while (! $self->has_valid_userid );
1326
1327      return $self;
1328
1329 }
1330
1331 =head3 attributes
1332
1333 my $attributes = $patron->attributes
1334
1335 Return object of Koha::Patron::Attributes type with all attributes set for this patron
1336
1337 =cut
1338
1339 sub attributes {
1340     my ( $self ) = @_;
1341     return Koha::Patron::Attributes->search({
1342         borrowernumber => $self->borrowernumber,
1343         branchcode     => $self->branchcode,
1344     });
1345 }
1346
1347 =head3 lock
1348
1349     Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1350
1351     Lock and optionally expire a patron account.
1352     Remove holds and article requests if remove flag set.
1353     In order to distinguish from locking by entering a wrong password, let's
1354     call this an administrative lockout.
1355
1356 =cut
1357
1358 sub lock {
1359     my ( $self, $params ) = @_;
1360     $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
1361     if( $params->{expire} ) {
1362         $self->dateexpiry( dt_from_string->subtract(days => 1) );
1363     }
1364     $self->store;
1365     if( $params->{remove} ) {
1366         $self->holds->delete;
1367         $self->article_requests->delete;
1368     }
1369     return $self;
1370 }
1371
1372 =head3 anonymize
1373
1374     Koha::Patrons->find($id)->anonymize;
1375
1376     Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1377     are randomized, other personal data is cleared too.
1378     Patrons with issues are skipped.
1379
1380 =cut
1381
1382 sub anonymize {
1383     my ( $self ) = @_;
1384     if( $self->_result->issues->count ) {
1385         warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1386         return;
1387     }
1388     # Mandatory fields come from the corresponding pref, but email fields
1389     # are removed since scrambled email addresses only generate errors
1390     my $mandatory = { map { (lc $_, 1); } grep { !/email/ }
1391         split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
1392     $mandatory->{userid} = 1; # needed since sub store does not clear field
1393     my @columns = $self->_result->result_source->columns;
1394     @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|anonymized/ } @columns;
1395     push @columns, 'dateofbirth'; # add this date back in
1396     foreach my $col (@columns) {
1397         $self->_anonymize_column($col, $mandatory->{lc $col} );
1398     }
1399     $self->anonymized(1)->store;
1400 }
1401
1402 sub _anonymize_column {
1403     my ( $self, $col, $mandatory ) = @_;
1404     my $col_info = $self->_result->result_source->column_info($col);
1405     my $type = $col_info->{data_type};
1406     my $nullable = $col_info->{is_nullable};
1407     my $val;
1408     if( $type =~ /char|text/ ) {
1409         $val = $mandatory
1410             ? Koha::Token->new->generate({ pattern => '\w{10}' })
1411             : $nullable
1412             ? undef
1413             : q{};
1414     } elsif( $type =~ /integer|int$|float|dec|double/ ) {
1415         $val = $nullable ? undef : 0;
1416     } elsif( $type =~ /date|time/ ) {
1417         $val = $nullable ? undef : dt_from_string;
1418     }
1419     $self->$col($val);
1420 }
1421
1422 =head2 Internal methods
1423
1424 =head3 _type
1425
1426 =cut
1427
1428 sub _type {
1429     return 'Borrower';
1430 }
1431
1432 =head1 AUTHORS
1433
1434 Kyle M Hall <kyle@bywatersolutions.com>
1435 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
1436 Martin Renvoize <martin.renvoize@ptfs-europe.com>
1437
1438 =cut
1439
1440 1;